diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 1d80a6d..e19157e 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -165,6 +165,7 @@ ApplicationWindow { clip: true model: [] delegate: ItemDelegate { + id: docDelegate required property var modelData width: docList.width highlighted: root.currentDoc === modelData.name @@ -174,8 +175,38 @@ ApplicationWindow { topPadding: root.baseFontSize * 0.4 bottomPadding: root.baseFontSize * 0.4 background: Rectangle { - color: highlighted ? theme.highlightColor : "transparent" - opacity: highlighted ? 0.3 : 1.0 + color: (docDrop.containsDrag) ? theme.highlightColor + : (docDelegate.highlighted ? theme.highlightColor : "transparent") + opacity: docDrop.containsDrag ? 0.5 + : (docDelegate.highlighted ? 0.3 : 1.0) + // While a section is dragged over a non-current doc, + // show a border that fills as the spring timer runs. + border.color: theme.linkColor + border.width: docDrop.containsDrag + && modelData.name !== root.currentDoc ? 1 : 0 + } + + // Spring-loaded cross-file drop target: hovering a + // dragged section over another document for a moment + // switches the view to it; the drop then lands in that + // file via the outline's normal in-file resolution. + DropArea { + id: docDrop + anchors.fill: parent + keys: ["ork-section"] + onEntered: { + if (docDelegate.modelData.name !== root.currentDoc) + springTimer.restart() + } + onExited: springTimer.stop() + Timer { + id: springTimer + interval: 450 + onTriggered: { + if (docDrop.containsDrag) + root.selectDoc(docDelegate.modelData.name) + } + } } contentItem: ColumnLayout { spacing: root.baseFontSize * 0.15 diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index fd13b24..794b208 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -19,23 +19,23 @@ ListView { property var insertTarget: null // ── Drag-and-drop structural move state ────────────────────────────── - // The visible row currently being dragged (-1 = none), the live resolved - // drop target from docModel.resolveDrop, and the gap geometry for drawing - // the insertion indicator. - property int dragRow: -1 + // A drag is scene-level (Qt Drag + DropArea) so the view can spring-load + // to another file mid-drag without cancelling it. dragActive is true for + // the duration; dropInfo is the live resolved target over the current + // outline; the indicator geometry is in content coordinates. + property bool dragActive: false property var dropInfo: null // {valid, level, parentId, parentTitle, beforeId} property real dropIndicatorY: 0 // content-y of the insertion bar - property int dropGapAbove: -2 // visible row above the gap (-1 = top) + property bool dropOverOutline: false // pointer currently over the outline // Recompute the drop target from a pointer position in content coords. + // The dragged section is tracked by the model (beginDrag), so resolveDrop + // needs only the gap and desired level. function updateDrop(contentX, contentY) { - if (dragRow < 0) return - // Which row is the pointer over? The gap is above or below its middle. + if (!dragActive) return var r = indexAt(1, contentY) - if (r < 0) { - // Past the last row → gap at the very bottom. + if (r < 0) r = count - 1 - } var item = itemAtIndex(r) var gapAbove var gapY @@ -46,24 +46,23 @@ ListView { } else { gapAbove = r; gapY = contentY } - // Desired level from horizontal indent. Row left padding is - // indent(level) + 8; one level ≈ indentWidth px. + // Desired level from horizontal indent (row left padding = indent + 8). var desired = Math.round((contentX - 8) / outline.indentWidth) + 1 if (desired < 1) desired = 1 - var info = docModel.resolveDrop(dragRow, gapAbove, desired) - outline.dropInfo = info - outline.dropGapAbove = gapAbove + outline.dropInfo = docModel.resolveDrop(gapAbove, desired) outline.dropIndicatorY = gapY } - // Commit the current drop (called on drag release). + // Commit the current drop into the current document (may be a different + // file than the drag started in — the model holds the source). function commitDrop() { - if (dragRow >= 0 && dropInfo && dropInfo.valid) { - docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId) - } - dragRow = -1 + if (dragActive && dropOverOutline && dropInfo && dropInfo.valid) + docModel.commitDrag(dropInfo.parentId, dropInfo.beforeId) + else + docModel.endDrag() + dragActive = false dropInfo = null - dropGapAbove = -2 + dropOverOutline = false } // Palette for TODO keywords by state. @@ -184,6 +183,31 @@ ListView { } } + // Drop target covering the outline viewport. Receives the scene drag + // (started from a row's grip) and resolves the in-file position live. + // Because the drag is scene-level, this DropArea keeps working even after + // the view springs to a different file mid-drag. + DropArea { + id: outlineDrop + anchors.fill: parent + z: 90 + keys: ["ork-section"] + + onEntered: outline.dropOverOutline = true + onExited: outline.dropOverOutline = false + onPositionChanged: (drag) => { + outline.dropOverOutline = true + // DropArea-local coords are viewport coords; add contentY for the + // content coordinate the row geometry uses. + outline.updateDrop(drag.x, drag.y + outline.contentY) + } + onDropped: (drop) => { + outline.dropOverOutline = true + outline.commitDrop() + drop.accept() + } + } + // Drop indicator shown while dragging a section. A horizontal insertion // bar at the resolved gap, indented to the resolved level, with a ghost // label naming the resolved level and parent. Driven entirely by @@ -191,7 +215,8 @@ ListView { Item { id: dropIndicator z: 100 - visible: outline.dragRow >= 0 && outline.dropInfo && outline.dropInfo.valid + visible: outline.dragActive && outline.dropOverOutline + && outline.dropInfo && outline.dropInfo.valid // Convert the content-y gap position to viewport coordinates. y: outline.dropIndicatorY - outline.contentY x: 0 @@ -517,21 +542,20 @@ ListView { Layout.fillWidth: true spacing: 8 - // Drag grip (left gutter): a dedicated drag control. Pressing - // it disables ListView flicking for the duration of the press, - // so the grip — not the enclosing Flickable — owns the gesture; - // dragging then restructures the section (and its subtree) and - // releasing commits a refile. Horizontal position chooses the - // depth, vertical the gap. Flicking is restored on release. + // Drag grip (left gutter): starts a scene-level drag of this + // section (and its subtree). The drag is a Qt Automatic drag so + // it is delivered to DropAreas across the window and survives + // the view springing to another file mid-drag. The outline's + // DropArea resolves the in-file position; a sidebar doc's + // DropArea spring-loads to that file. Rectangle { id: gripLabel Layout.preferredWidth: outline.baseFontSize + 8 Layout.preferredHeight: outline.baseFontSize + 8 radius: 3 - readonly property bool active: outline.dragRow === del.index - || gripPress.pressed + readonly property bool active: gripMouse.pressed color: active ? theme.highlightColor - : (gripHover.hovered ? theme.alternateBaseColor : "transparent") + : (gripMouse.containsMouse ? theme.alternateBaseColor : "transparent") Label { anchors.centerIn: parent @@ -539,49 +563,51 @@ ListView { color: gripLabel.active ? theme.highlightedTextColor : theme.placeholderTextColor font.pointSize: outline.baseFontSize - 1 - opacity: gripHover.hovered || gripLabel.active ? 1.0 : 0.4 + opacity: gripMouse.containsMouse || gripLabel.active ? 1.0 : 0.4 } - HoverHandler { - id: gripHover - cursorShape: gripLabel.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor + // Invisible proxy that carries the drag payload. Starting a + // Qt Automatic drag on it hands the gesture to the window's + // drag manager, independent of this (destructible) delegate. + Item { + id: dragProxy + anchors.fill: parent + Drag.active: false + Drag.dragType: Drag.Automatic + Drag.supportedActions: Qt.MoveAction + Drag.keys: ["ork-section"] } - // Own the gesture: while the grip is pressed, the ListView - // must not flick. This is what makes the gutter behave as a - // separate control rather than fighting the Flickable. - TapHandler { - id: gripPress - onPressedChanged: outline.interactive = !pressed - } - - // The structural drag. The Flickable is non-interactive - // while pressed, so the handler takes the grab cleanly. - DragHandler { - id: gripDrag - target: null // track only; don't move the item - dragThreshold: 3 - grabPermissions: PointerHandler.CanTakeOverFromItems - | PointerHandler.CanTakeOverFromHandlersOfDifferentType - - onActiveChanged: { - if (active) { - outline.dragRow = del.index - } else { - outline.commitDrop() - outline.interactive = true // belt-and-suspenders + MouseArea { + id: gripMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: gripLabel.active ? Qt.ClosedHandCursor + : Qt.OpenHandCursor + preventStealing: true + property bool started: false + onPressed: started = false + onPositionChanged: (mouse) => { + if (started) return + started = true + // Record the drag source in the model (survives a + // later file switch) and begin the OS drag. + docModel.beginDrag(del.index) + outline.dragActive = true + dragProxy.Drag.mimeData = { + "text/plain": docModel.dragSourceDoc() + + "\t" + docModel.dragSourceId() } + dragProxy.Drag.active = true + dragProxy.Drag.startDrag() } - onCentroidChanged: { - if (!active || outline.dragRow < 0) return - // Map the scene pointer position to ListView content - // coordinates. mapFromItem(null, ...) treats the - // source coordinates as scene coordinates. - var p = outline.contentItem.mapFromItem( - null, - centroid.scenePosition.x, - centroid.scenePosition.y) - outline.updateDrop(p.x, p.y) + onReleased: { + // If the drag never produced a drop, clean up. + if (outline.dragActive && !started) { + outline.dragActive = false + docModel.endDrag() + } + started = false } } } diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index e91152f..af178ee 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -555,30 +555,6 @@ bool DocumentModel::demoteSection(int row) return true; } -bool DocumentModel::refileSection(int row, const QString &parentId, const QString &beforeId) -{ - if (!m_client || m_document.isEmpty() || row < 0 || row >= m_visibleRows.size()) - return false; - const QString id = m_visibleRows[row]; - QString doc = m_document; - if (doc.startsWith('/')) - doc = doc.mid(1); - // refile [parent-id] [before-id]; same-doc refile uses - // doc as to-doc. A "-" placeholder keeps positional args when only - // before-id is given (parent empty = top level). - QString cmd = QStringLiteral("refile %1 %2 %1").arg(doc, id); - const QString parent = parentId.isEmpty() ? QStringLiteral("-") : parentId; - if (!beforeId.isEmpty()) - cmd += QStringLiteral(" %1 %2").arg(parent, beforeId); - else if (!parentId.isEmpty()) - cmd += QStringLiteral(" %1").arg(parentId); - const QString reply = m_client->rdwr(QStringLiteral("/ctl"), cmd); - if (!reply.startsWith(QLatin1String("ok"))) - return false; - reload(); - return true; -} - QString DocumentModel::sectionIdAt(int row) { if (row < 0 || row >= m_visibleRows.size()) @@ -586,28 +562,85 @@ QString DocumentModel::sectionIdAt(int row) return m_visibleRows[row]; } -QVariantMap DocumentModel::resolveDrop(int draggedRow, int gapAboveRow, int desiredLevel) +QString DocumentModel::beginDrag(int row) +{ + if (row < 0 || row >= m_visibleRows.size()) { + m_dragSourceDoc.clear(); + m_dragSourceId.clear(); + return QString(); + } + m_dragSourceId = m_visibleRows[row]; + m_dragSourceDoc = m_document; + if (m_dragSourceDoc.startsWith('/')) + m_dragSourceDoc = m_dragSourceDoc.mid(1); + return m_dragSourceId; +} + +void DocumentModel::endDrag() +{ + m_dragSourceDoc.clear(); + m_dragSourceId.clear(); +} + +bool DocumentModel::commitDrag(const QString &parentId, const QString &beforeId) +{ + if (!m_client || m_dragSourceDoc.isEmpty() || m_dragSourceId.isEmpty()) + return false; + + QString toDoc = m_document; + if (toDoc.startsWith('/')) + toDoc = toDoc.mid(1); + + // refile [parent-id] [before-id]. A "-" in the + // parent slot means top level (lets a before-id be given at top level). + const QString parent = parentId.isEmpty() ? QStringLiteral("-") : parentId; + QString cmd = QStringLiteral("refile %1 %2 %3") + .arg(m_dragSourceDoc, m_dragSourceId, toDoc); + if (!beforeId.isEmpty()) + cmd += QStringLiteral(" %1 %2").arg(parent, beforeId); + else if (!parentId.isEmpty()) + cmd += QStringLiteral(" %1").arg(parentId); + + const QString reply = m_client->rdwr(QStringLiteral("/ctl"), cmd); + endDrag(); + if (!reply.startsWith(QLatin1String("ok"))) + return false; + reload(); + return true; +} + +QVariantMap DocumentModel::resolveDrop(int gapAboveRow, int desiredLevel) { QVariantMap result; result[QStringLiteral("valid")] = false; - if (draggedRow < 0 || draggedRow >= m_visibleRows.size()) - return result; - const QString draggedId = m_visibleRows[draggedRow]; - if (!m_sections.contains(draggedId)) - return result; + // Determine whether the drag source lives in the current view. If so, its + // subtree is excluded (can't drop into itself); for a cross-file drag the + // source isn't present, so there's nothing to exclude. + QString currentDoc = m_document; + if (currentDoc.startsWith('/')) + currentDoc = currentDoc.mid(1); + const bool sameDoc = (m_dragSourceDoc == currentDoc) + && m_sections.contains(m_dragSourceId); - // The rows covered by the dragged subtree (contiguous in visible order, - // from draggedRow until a row at level <= dragged level). - const int dragLevel = m_sections[draggedId].level; - int dragEnd = draggedRow + 1; // exclusive - while (dragEnd < m_visibleRows.size()) { - const QString rid = m_visibleRows[dragEnd]; - if (!m_sections.contains(rid)) break; - if (m_sections[rid].level <= dragLevel) break; - ++dragEnd; + int draggedRow = -1; + int dragEnd = -1; // exclusive end of the dragged subtree in visible order + if (sameDoc) { + draggedRow = m_visibleRows.indexOf(m_dragSourceId); + if (draggedRow >= 0) { + const int dragLevel = m_sections[m_dragSourceId].level; + dragEnd = draggedRow + 1; + while (dragEnd < m_visibleRows.size()) { + const QString rid = m_visibleRows[dragEnd]; + if (!m_sections.contains(rid)) break; + if (m_sections[rid].level <= dragLevel) break; + ++dragEnd; + } + } } - auto inDragged = [&](int r) { return r >= draggedRow && r < dragEnd; }; + auto inDragged = [&](int r) { + return draggedRow >= 0 && r >= draggedRow && r < dragEnd; + }; // Row A directly above the gap (skip the dragged subtree), and its level. int aRow = gapAboveRow; diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 6273e40..dcdb958 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -141,25 +141,38 @@ public: Q_INVOKABLE bool promoteSection(int row); Q_INVOKABLE bool demoteSection(int row); - // Refile the subtree at `row` under `parentId` (empty = top level), - // inserted before `beforeId` (empty = append at the end of the parent's - // children / end of document). Uses the /ctl refile verb. - Q_INVOKABLE bool refileSection(int row, const QString &parentId, - const QString &beforeId); - // The section id at a visible row (empty if unknown). Q_INVOKABLE QString sectionIdAt(int row); - // Resolve a drag-and-drop drop point to an authoritative target. - // `gapAboveRow` is the visible row directly above the insertion gap (-1 = - // the gap at the very top). `desiredLevel` is the heading level the user - // is aiming at via horizontal indent. The result is clamped to the legal - // range for that gap and returned as a map: - // { valid, level, parentId, parentTitle, beforeId } - // where parentId/beforeId are empty for top level / append. The dragged - // row is excluded so a subtree cannot be dropped into itself. - Q_INVOKABLE QVariantMap resolveDrop(int draggedRow, int gapAboveRow, - int desiredLevel); + // ── Drag-and-drop refile (scene-level, cross-file capable) ─────────── + // The drag source is captured independently of the currently-viewed + // document so the view can "spring-load" to another file mid-drag without + // losing the dragged subtree. + + // Begin a drag: record (sourceDoc, sourceId) from the current view for the + // section at `row`. Returns the source id ("" if the row is invalid). + Q_INVOKABLE QString beginDrag(int row); + + // The document and id captured by beginDrag (for the drag payload / UI). + Q_INVOKABLE QString dragSourceDoc() const { return m_dragSourceDoc; } + Q_INVOKABLE QString dragSourceId() const { return m_dragSourceId; } + + // Resolve a drop point against the CURRENT view. `gapAboveRow` is the + // visible row directly above the insertion gap (-1 = top). `desiredLevel` + // is the heading level the pointer's indent aims at. Clamped to the gap's + // legal range. Returns { valid, level, parentId, parentTitle, beforeId }. + // The drag-source subtree is excluded only when it lives in the current + // document (so a subtree can't be dropped into itself); for a cross-file + // drag the source isn't present here, so nothing is excluded. + Q_INVOKABLE QVariantMap resolveDrop(int gapAboveRow, int desiredLevel); + + // Commit the active drag: refile (sourceDoc, sourceId) into the CURRENT + // document under `parentId` (empty = top level), before `beforeId` (empty + // = append). Clears the drag state. Returns true on success. + Q_INVOKABLE bool commitDrag(const QString &parentId, const QString &beforeId); + + // Abandon the active drag without changes. + Q_INVOKABLE void endDrag(); // Reveal a section by id: expand all its ancestors so it becomes visible, // and return its visible row index (-1 if the id is unknown). Emits @@ -250,6 +263,10 @@ private: // Visible row order (section ids) QStringList m_visibleRows; + // Active drag source (captured by beginDrag; survives view/doc switches). + QString m_dragSourceDoc; + QString m_dragSourceId; + // Supported src-block languages from `/ctl languages` (loaded once). mutable QStringList m_supportedLanguages; mutable bool m_languagesLoaded = false;