Add drag-and-drop structural moves with a live drop indicator
Drag a section by its ⠿ grip to restructure the outline. Vertical pointer
position chooses the insertion gap; horizontal position (indent) chooses
the depth. A live indicator shows the resolved target — an insertion bar
indented to the resolved level plus a ghost label ("L3 under \"B\"") —
computed by the same function that commits, so the preview is truthful.
Resolving the ambiguity (is this a new L2, an L3 sibling, or an L4
child?): at a gap between row A (level La, above) and row B (level Lb,
below), the legal depth range is [max(1, Lb), La + 1]. The pointer's
indent is clamped into that range; the parent is then computed by walking
A's ancestor chain to the row at level-1 (never guessed), and the
before-sibling is the first row after the gap at that level under that
parent. The dragged subtree is excluded, so it can't drop into itself.
Server (refile insert-before):
- refile_section gains a `before` parameter: insert the subtree
immediately before that sibling instead of appending at the end of the
parent. The ctl verb becomes
`refile <doc> <id> <to-doc> [parent-id] [before-id]`, and a literal "-"
in the parent slot means top level (so a before-id can be given while
refiling to the top).
Model:
- moveSection/promoteSection/demoteSection/refileSection drive the move,
promote, demote, and refile ctl verbs and reload on success.
- resolveDrop(draggedRow, gapAboveRow, desiredLevel) implements the
clamp + ancestor-walk and returns {valid, level, parentId, parentTitle,
beforeId}. sectionIdAt(row) exposes the row's id.
GUI:
- A drag grip per headline with a manual-drag MouseArea mapping the
pointer to ListView content coords and calling resolveDrop live; the
drop indicator overlay renders from the result; release commits via
refileSection.
Verified: server tests (75 passed, +3: refile before-sibling same-doc,
before top-level, and the ctl "-" top-level placeholder). The drop
resolution was checked with a standalone harness over the ambiguous
C|E-gap cases (indent 1/2/3/4 → top-before-E / L2-under-A / L3-under-B /
L4-under-C, clamping, and self-drop exclusion). Live 9P confirmed
`refile d f d b c` (reparent + relevel + insert-before) and
`refile d f d - a` (promote to top, insert-before). GUI builds clean.
This commit is contained in:
parent
9ed3b730e6
commit
1033820229
|
|
@ -355,7 +355,7 @@ impl OrkState {
|
|||
("move", "move <doc> <id> up|down — reorder among siblings", OrkState::ctl_move),
|
||||
("promote", "promote <doc> <id> — decrease subtree level", OrkState::ctl_promote),
|
||||
("demote", "demote <doc> <id> — increase subtree level", OrkState::ctl_demote),
|
||||
("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile),
|
||||
("refile", "refile <doc> <id> <to-doc> [parent-id] [before-id] — move subtree", OrkState::ctl_refile),
|
||||
("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive),
|
||||
("lint", "lint [doc] — validate documents", OrkState::ctl_lint),
|
||||
("execute", "execute <doc> [scope] [block] — run src blocks", OrkState::ctl_execute),
|
||||
|
|
@ -429,11 +429,17 @@ impl OrkState {
|
|||
}
|
||||
|
||||
fn ctl_refile(&self, args: &[&str]) -> Result<String> {
|
||||
if args.len() < 3 || args.len() > 4 {
|
||||
return Err(bad_args("refile <doc> <id> <to-doc> [parent-id]"));
|
||||
if args.len() < 3 || args.len() > 5 {
|
||||
return Err(bad_args("refile <doc> <id> <to-doc> [parent-id] [before-id]"));
|
||||
}
|
||||
let parent = args.get(3).copied();
|
||||
self.refile_section(args[0], args[1], args[2], parent)?;
|
||||
// A literal "-" in the parent slot means "top level" (no parent); this
|
||||
// lets callers specify a before-id while refiling to top level.
|
||||
let parent = match args.get(3).copied() {
|
||||
Some("-") | Some("") => None,
|
||||
other => other,
|
||||
};
|
||||
let before = args.get(4).copied();
|
||||
self.refile_section(args[0], args[1], args[2], parent, before)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
|
|
@ -1826,12 +1832,17 @@ impl OrkState {
|
|||
/// Move a section's subtree to another document (or elsewhere in the same
|
||||
/// document), re-leveling it to the target depth. `to_parent` places it as
|
||||
/// a child of that section; otherwise it becomes a top-level section.
|
||||
/// `before` (if given) inserts the subtree immediately before that sibling
|
||||
/// (which must exist at the insertion point after re-leveling); otherwise
|
||||
/// the subtree is appended at the end of the parent's children (or the end
|
||||
/// of the document for a top-level refile).
|
||||
pub fn refile_section(
|
||||
&self,
|
||||
doc_name: &str,
|
||||
id: &str,
|
||||
to_doc: &str,
|
||||
to_parent: Option<&str>,
|
||||
before: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let src = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?;
|
||||
|
|
@ -1859,37 +1870,42 @@ impl OrkState {
|
|||
subtree.push('\n');
|
||||
}
|
||||
|
||||
// Same-document refile: compute both edits against one content string.
|
||||
// Resolve the insertion byte offset within `content`, given the parsed
|
||||
// `doc`. `before` takes precedence (insert at that sibling's headline
|
||||
// start); otherwise insert at the end of the parent, or end of content.
|
||||
let insert_pos = |content: &str, doc: &Document| -> Result<usize> {
|
||||
if let Some(bid) = before {
|
||||
let b = self.find_section(doc, bid)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "before section not found"))?;
|
||||
return Ok(b.headline.span.start.offset);
|
||||
}
|
||||
match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(doc, pid)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
|
||||
Ok(self.find_section_end(content, parent))
|
||||
}
|
||||
None => Ok(content.len()),
|
||||
}
|
||||
};
|
||||
|
||||
// Same-document refile: remove the subtree first, then recompute the
|
||||
// insertion point on the post-removal (reparsed) content.
|
||||
if doc_name == to_doc {
|
||||
// Remove the subtree, then recompute the insertion point on the
|
||||
// post-removal content by reparsing.
|
||||
let mut content = String::with_capacity(src.content.len());
|
||||
content.push_str(&src.content[..start]);
|
||||
content.push_str(&src.content[end..]);
|
||||
self.update_doc(doc_name, &src.path, content)?;
|
||||
|
||||
let doc_state = self.get_doc(to_doc).unwrap();
|
||||
let insert_pos = match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(&doc_state.doc, pid)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
|
||||
self.find_section_end(&doc_state.content, parent)
|
||||
}
|
||||
None => doc_state.content.len(),
|
||||
};
|
||||
let new = insert_at(&doc_state.content, insert_pos, &subtree);
|
||||
let pos = insert_pos(&doc_state.content, &doc_state.doc)?;
|
||||
let new = insert_at(&doc_state.content, pos, &subtree);
|
||||
return self.update_doc(to_doc, &doc_state.path, new);
|
||||
}
|
||||
|
||||
// Cross-document refile: insert into target, then remove from source.
|
||||
let insert_pos = match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(&dst.doc, pid).unwrap();
|
||||
self.find_section_end(&dst.content, parent)
|
||||
}
|
||||
None => dst.content.len(),
|
||||
};
|
||||
let dst_new = insert_at(&dst.content, insert_pos, &subtree);
|
||||
let pos = insert_pos(&dst.content, &dst.doc)?;
|
||||
let dst_new = insert_at(&dst.content, pos, &subtree);
|
||||
self.update_doc(to_doc, &dst.path, dst_new)?;
|
||||
|
||||
let mut src_new = String::with_capacity(src.content.len());
|
||||
|
|
@ -2949,7 +2965,7 @@ mod tests {
|
|||
fs::write(dir.path().join("b.org"), "#+TITLE: B\n* Target\n").unwrap();
|
||||
let state = OrkState::new(dir.path()).unwrap();
|
||||
|
||||
state.refile_section("a", "move-me", "b", Some("target")).unwrap();
|
||||
state.refile_section("a", "move-me", "b", Some("target"), None).unwrap();
|
||||
|
||||
let a = state.doc_raw("a").unwrap();
|
||||
let b = state.doc_raw("b").unwrap();
|
||||
|
|
@ -2958,6 +2974,49 @@ mod tests {
|
|||
assert!(b.contains("body"), "body carried over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refile_before_sibling_same_doc() {
|
||||
// Refile a top-level section to become a child of Parent, inserted
|
||||
// before the existing child "Beta" (not appended after it).
|
||||
let (_d, state) = state_with(
|
||||
"#+TITLE: W\n* Parent\n** Alpha\n** Beta\n* Mover\nbody\n",
|
||||
);
|
||||
state.refile_section("w", "mover", "w", Some("parent"), Some("beta")).unwrap();
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
// Mover becomes a level-2 child of Parent, positioned before Beta.
|
||||
let a = raw.find("** Alpha").unwrap();
|
||||
let m = raw.find("** Mover").unwrap();
|
||||
let b = raw.find("** Beta").unwrap();
|
||||
assert!(a < m && m < b, "order should be Alpha, Mover, Beta; got {:?}", raw);
|
||||
assert!(raw.contains("body"), "body carried over");
|
||||
assert!(!raw.starts_with("* Mover"), "no longer top-level");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refile_before_top_level() {
|
||||
// Refile under no parent (top level), before an existing top section.
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n** Child\n");
|
||||
state.refile_section("w", "child", "w", None, Some("one")).unwrap();
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
let c = raw.find("* Child").unwrap();
|
||||
let one = raw.find("* One").unwrap();
|
||||
assert!(c < one, "Child promoted to top level before One; got {:?}", raw);
|
||||
// Promoted from level 2 to level 1.
|
||||
assert!(raw.contains("\n* Child") || raw.starts_with("* Child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctl_refile_dash_parent_means_top_level() {
|
||||
// Via the ctl verb, "-" in the parent slot means top level, so a
|
||||
// before-id can be given while refiling to the top.
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n** Child\n");
|
||||
let reply = state.handle_ctl("refile w child w - one").unwrap();
|
||||
assert_eq!(reply.trim(), "ok");
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
assert!(raw.find("* Child").unwrap() < raw.find("* One").unwrap(),
|
||||
"Child at top before One; got {:?}", raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_section() {
|
||||
use std::fs;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,54 @@ ListView {
|
|||
// here so the shared date picker can insert into it at the cursor.
|
||||
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
|
||||
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)
|
||||
|
||||
// Recompute the drop target from a pointer position in content coords.
|
||||
function updateDrop(contentX, contentY) {
|
||||
if (dragRow < 0) return
|
||||
// Which row is the pointer over? The gap is above or below its middle.
|
||||
var r = indexAt(1, contentY)
|
||||
if (r < 0) {
|
||||
// Past the last row → gap at the very bottom.
|
||||
r = count - 1
|
||||
}
|
||||
var item = itemAtIndex(r)
|
||||
var gapAbove
|
||||
var gapY
|
||||
if (item) {
|
||||
var mid = item.y + item.height / 2
|
||||
if (contentY < mid) { gapAbove = r - 1; gapY = item.y }
|
||||
else { gapAbove = r; gapY = item.y + item.height }
|
||||
} else {
|
||||
gapAbove = r; gapY = contentY
|
||||
}
|
||||
// Desired level from horizontal indent. Row left padding is
|
||||
// indent(level) + 8; one level ≈ indentWidth px.
|
||||
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.dropIndicatorY = gapY
|
||||
}
|
||||
|
||||
// Commit the current drop (called on drag release).
|
||||
function commitDrop() {
|
||||
if (dragRow >= 0 && dropInfo && dropInfo.valid) {
|
||||
docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId)
|
||||
}
|
||||
dragRow = -1
|
||||
dropInfo = null
|
||||
dropGapAbove = -2
|
||||
}
|
||||
|
||||
// Palette for TODO keywords by state.
|
||||
function keywordColor(kw) {
|
||||
switch (kw) {
|
||||
|
|
@ -136,6 +184,66 @@ ListView {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// outline.dropInfo so the preview matches exactly what commitDrop() does.
|
||||
Item {
|
||||
id: dropIndicator
|
||||
z: 100
|
||||
visible: outline.dragRow >= 0 && outline.dropInfo && outline.dropInfo.valid
|
||||
// Convert the content-y gap position to viewport coordinates.
|
||||
y: outline.dropIndicatorY - outline.contentY
|
||||
x: 0
|
||||
width: outline.width
|
||||
height: 2
|
||||
|
||||
// Indent of the insertion bar = left padding for the resolved level.
|
||||
readonly property int barIndent: visible
|
||||
? 8 + (outline.dropInfo.level - 1) * outline.indentWidth : 0
|
||||
|
||||
Rectangle {
|
||||
x: dropIndicator.barIndent
|
||||
width: parent.width - x - 8
|
||||
height: 2
|
||||
y: -1
|
||||
color: theme.linkColor
|
||||
radius: 1
|
||||
}
|
||||
// Left cap circle at the insertion point.
|
||||
Rectangle {
|
||||
x: dropIndicator.barIndent - 3
|
||||
y: -3
|
||||
width: 6; height: 6; radius: 3
|
||||
color: theme.linkColor
|
||||
}
|
||||
// Ghost label: "L{level} under \"{parent}\"" (or "top level").
|
||||
Rectangle {
|
||||
x: dropIndicator.barIndent
|
||||
y: 2
|
||||
width: ghostLabel.implicitWidth + 8
|
||||
height: ghostLabel.implicitHeight + 2
|
||||
radius: 3
|
||||
color: theme.windowColor
|
||||
border.color: theme.linkColor
|
||||
border.width: 1
|
||||
Label {
|
||||
id: ghostLabel
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
if (!outline.dropInfo || !outline.dropInfo.valid) return ""
|
||||
var lvl = "L" + outline.dropInfo.level
|
||||
var p = (outline.dropInfo.parentTitle && outline.dropInfo.parentTitle.length > 0)
|
||||
? qsTr("under \"%1\"").arg(outline.dropInfo.parentTitle)
|
||||
: qsTr("at top level")
|
||||
return lvl + " " + p
|
||||
}
|
||||
color: theme.textColor
|
||||
font.pointSize: outline.baseFontSize - 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: del
|
||||
width: outline.width
|
||||
|
|
@ -409,6 +517,39 @@ ListView {
|
|||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
// Drag grip: drag a section (and its subtree) to restructure.
|
||||
// A live indicator shows the resolved drop target; releasing
|
||||
// commits a refile. Horizontal position chooses the depth,
|
||||
// vertical position chooses the gap.
|
||||
Label {
|
||||
text: "⠿"
|
||||
color: theme.placeholderTextColor
|
||||
font.pointSize: outline.baseFontSize - 1
|
||||
opacity: gripMouse.containsMouse || outline.dragRow === del.index ? 1.0 : 0.3
|
||||
|
||||
MouseArea {
|
||||
id: gripMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.SizeAllCursor
|
||||
preventStealing: true
|
||||
onPressed: {
|
||||
outline.dragRow = del.index
|
||||
}
|
||||
onPositionChanged: (mouse) => {
|
||||
if (outline.dragRow < 0) return
|
||||
// Map the pointer to ListView content coordinates.
|
||||
var p = mapToItem(outline.contentItem, mouse.x, mouse.y)
|
||||
outline.updateDrop(p.x, p.y)
|
||||
}
|
||||
onReleased: outline.commitDrop()
|
||||
onCanceled: {
|
||||
outline.dragRow = -1
|
||||
outline.dropInfo = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/collapse indicator for sections with children
|
||||
Label {
|
||||
visible: (del.model.hasChildren ?? false)
|
||||
|
|
|
|||
|
|
@ -504,6 +504,180 @@ bool DocumentModel::setBody(int row, const QString &body)
|
|||
return true;
|
||||
}
|
||||
|
||||
// Issue a /ctl command and return true if it succeeded (reply starts "ok").
|
||||
// On success the model is reloaded, since structural edits change the tree.
|
||||
bool DocumentModel::moveSection(int row, bool up)
|
||||
{
|
||||
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);
|
||||
const QString cmd = QStringLiteral("move %1 %2 %3")
|
||||
.arg(doc, id, up ? QStringLiteral("up") : QStringLiteral("down"));
|
||||
const QString reply = m_client->rdwr(QStringLiteral("/ctl"), cmd);
|
||||
if (!reply.startsWith(QLatin1String("ok")))
|
||||
return false;
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DocumentModel::promoteSection(int row)
|
||||
{
|
||||
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);
|
||||
const QString reply = m_client->rdwr(QStringLiteral("/ctl"),
|
||||
QStringLiteral("promote %1 %2").arg(doc, id));
|
||||
if (!reply.startsWith(QLatin1String("ok")))
|
||||
return false;
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DocumentModel::demoteSection(int row)
|
||||
{
|
||||
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);
|
||||
const QString reply = m_client->rdwr(QStringLiteral("/ctl"),
|
||||
QStringLiteral("demote %1 %2").arg(doc, id));
|
||||
if (!reply.startsWith(QLatin1String("ok")))
|
||||
return false;
|
||||
reload();
|
||||
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 <doc> <id> <to-doc> [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())
|
||||
return QString();
|
||||
return m_visibleRows[row];
|
||||
}
|
||||
|
||||
QVariantMap DocumentModel::resolveDrop(int draggedRow, 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
auto inDragged = [&](int r) { return r >= draggedRow && r < dragEnd; };
|
||||
|
||||
// Row A directly above the gap (skip the dragged subtree), and its level.
|
||||
int aRow = gapAboveRow;
|
||||
while (aRow >= 0 && inDragged(aRow))
|
||||
--aRow;
|
||||
const int aLevel = (aRow >= 0 && m_sections.contains(m_visibleRows[aRow]))
|
||||
? m_sections[m_visibleRows[aRow]].level : 0;
|
||||
|
||||
// Row B directly below the gap (skip the dragged subtree), and its level.
|
||||
int bRow = gapAboveRow + 1;
|
||||
while (bRow < m_visibleRows.size() && inDragged(bRow))
|
||||
++bRow;
|
||||
const int bLevel = (bRow < m_visibleRows.size() && m_sections.contains(m_visibleRows[bRow]))
|
||||
? m_sections[m_visibleRows[bRow]].level : 0;
|
||||
|
||||
// Legal depth range for this gap: no deeper than one below A, no shallower
|
||||
// than B (else we'd land inside the wrong subtree). Clamp the desired.
|
||||
const int hi = (aRow >= 0) ? aLevel + 1 : 1;
|
||||
const int lo = (bLevel > 0) ? bLevel : 1;
|
||||
int level = desiredLevel;
|
||||
if (level < lo) level = lo;
|
||||
if (level > hi) level = hi;
|
||||
if (level < 1) level = 1;
|
||||
|
||||
// Parent = the nearest section at or above the gap whose level is level-1.
|
||||
// Walk up A's ancestor chain (A itself if level == aLevel+1).
|
||||
QString parentId;
|
||||
QString parentTitle;
|
||||
if (level > 1) {
|
||||
int walk = aRow;
|
||||
while (walk >= 0) {
|
||||
const QString rid = m_visibleRows[walk];
|
||||
if (m_sections.contains(rid) && m_sections[rid].level == level - 1) {
|
||||
parentId = rid;
|
||||
parentTitle = m_sections[rid].title;
|
||||
break;
|
||||
}
|
||||
--walk;
|
||||
}
|
||||
// If no ancestor at level-1 exists, the drop is not legal.
|
||||
if (parentId.isEmpty())
|
||||
return result;
|
||||
}
|
||||
|
||||
// Before-sibling = first row after the gap (skipping the dragged subtree)
|
||||
// at exactly `level` under the resolved parent. If we first hit a row at a
|
||||
// shallower level, we've left the parent → append (empty beforeId).
|
||||
QString beforeId;
|
||||
for (int r = bRow; r < m_visibleRows.size(); ++r) {
|
||||
if (inDragged(r)) continue;
|
||||
const QString rid = m_visibleRows[r];
|
||||
if (!m_sections.contains(rid)) break;
|
||||
const int rl = m_sections[rid].level;
|
||||
if (rl < level) break; // left the parent's children
|
||||
if (rl == level) { // candidate sibling
|
||||
if (m_sections[rid].parentId == parentId)
|
||||
beforeId = rid;
|
||||
break;
|
||||
}
|
||||
// rl > level: still inside an earlier sibling's subtree; keep scanning.
|
||||
}
|
||||
|
||||
result[QStringLiteral("valid")] = true;
|
||||
result[QStringLiteral("level")] = level;
|
||||
result[QStringLiteral("parentId")] = parentId;
|
||||
result[QStringLiteral("parentTitle")] = parentTitle;
|
||||
result[QStringLiteral("beforeId")] = beforeId;
|
||||
return result;
|
||||
}
|
||||
|
||||
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||
{
|
||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||
|
|
|
|||
|
|
@ -130,6 +130,37 @@ public:
|
|||
// for seeding an inline editor. Empty if the row or section is unknown.
|
||||
Q_INVOKABLE QString bodyText(int row);
|
||||
|
||||
// Structural edits via the server's /ctl verbs. Each operates on the
|
||||
// section at `row` (and its whole subtree), reparses, and reloads the
|
||||
// model. Returns true on success; on failure the server's message is left
|
||||
// in the OrkClient error.
|
||||
//
|
||||
// moveSection: reorder among siblings (up = earlier, else later).
|
||||
// promoteSection/demoteSection: shift the subtree's heading level.
|
||||
Q_INVOKABLE bool moveSection(int row, bool up);
|
||||
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);
|
||||
|
||||
// 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
|
||||
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
|
||||
|
|
|
|||
Loading…
Reference in New Issue