Fix structure tree drag feedback: stable ➕ rows, on-row child drops
Rework the site structure tree's drag-and-drop to match Sortable's native
model instead of mid-drag drop zones:
- Drop the dashed empty-list drop zones that appeared on drag start
(flicker, space reservation, and mis-targeted drops: the last item's
child zone sat exactly where 'end of parent list' was).
- Every non-empty list (and the root) ends with a non-draggable ➕ footer
row (vuedraggable #footer slot): click to start a new page at that
level, and while dragging it is the 'end of this list' drop target.
It renders last even mid-drag via flex order, since Sortable appends
end-of-list previews after it in the DOM.
- Dropping ON the lower part of a row makes the page that row's first
child: the child list's container invisibly overlaps its own row's
bottom (negative margin + equal padding, no layout effect) and becomes
hit-testable only while dragging. A row's exposed top strip remains
the 'sibling before' target. Leaf rows work too, creating a sublist.
- Indentation is structural (each nested list margin-indents itself), so
a dragged row previews its whole subtree at the target list's depth.
Rows get a little vertical padding to widen the drop zones.
This commit is contained in:
@@ -100,8 +100,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
the banner pen — everything saves immediately as you edit (brand/title
|
||||
debounced, slug on commit since it renames the path), tree rows navigate
|
||||
in place without transitions when focused, and the front page is a
|
||||
root-only row whose empty slug is editable like any other (empty child
|
||||
lists become drop zones while dragging). The two pens swap the docked
|
||||
root-only row whose empty slug is editable like any other. Every
|
||||
non-empty list (and the root) ends with a non-draggable ➕ footer row
|
||||
(vuedraggable `#footer` slot): clicking it starts a new pending page at
|
||||
that level, and while dragging it is the list's "end of list" drop
|
||||
target. Dropping ON the lower part of a row moves the page under that
|
||||
row (the child list's container invisibly overlaps its own row's bottom
|
||||
via negative margin — Sortable inserts it as the first child natively),
|
||||
while a row's exposed top edge inserts a sibling before it. Row
|
||||
indentation is structural (each nested list margin-indents itself), so a
|
||||
dragged row previews its whole subtree at the target list's depth. The
|
||||
two pens swap the docked
|
||||
panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by
|
||||
pagerite.js when a 🖊️ edit link is clicked (the link carries
|
||||
`data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/_/admin`
|
||||
|
||||
@@ -167,14 +167,18 @@ evolves.
|
||||
insert `` at the cursor.
|
||||
- The **structure panel** (vue-draggable tree of the whole site, in site
|
||||
mode) covers page management: reorder any menu level, drag across
|
||||
sections (empty child lists appear as drop zones while dragging), add,
|
||||
delete (two clicks: the button arms, then deletes — no dialogs).
|
||||
Every node is a real label — content-less category rows offer a
|
||||
➕ to give them a landing page.
|
||||
sections, add, delete (two clicks: the button arms, then deletes — no
|
||||
dialogs). Every node is a real label — content-less category rows offer
|
||||
a ➕ to give them a landing page.
|
||||
Deleting a category removes only its landing page (the label and its
|
||||
subpages stay). The ➕ in the panel header starts a new page as a
|
||||
local-only tree row that can be dragged into place before its title and
|
||||
slug are filled in; it is persisted only on commit. Rows are always
|
||||
subpages stay). Every non-empty list ends with a ➕ row that starts a
|
||||
new page as a local-only tree row at that level; the row can be dragged
|
||||
into place before its title and slug are filled in and is persisted only
|
||||
on commit. While dragging, these ➕ rows double as "end of this list"
|
||||
drop targets; dropping ON the lower part of a row makes the page that
|
||||
row's first child (even a leaf's, creating a sublist), while a row's
|
||||
exposed top edge inserts a sibling before it. A dragged row's
|
||||
indentation previews the target list's depth. Rows are always
|
||||
editable: titles save while typing, slug edits commit on blur/Enter
|
||||
since they rename the path (moving the whole subtree). The front page
|
||||
is the root row with an empty slug — renaming it away leaves no front
|
||||
|
||||
@@ -157,12 +157,13 @@ function followMove(oldPath, newPath) {
|
||||
}
|
||||
|
||||
// --- New page flow -------------------------------------------------------
|
||||
// The ➕ in the pages header adds a *pending* row to the tree: a local-only
|
||||
// item that can be dragged into place before anything is filled in. It is
|
||||
// persisted only on commit (✓/Enter), at wherever it currently sits.
|
||||
// The ➕ row at the end of any list adds a *pending* row there: a
|
||||
// local-only item that can be dragged into place before anything is
|
||||
// filled in. It is persisted only on commit (✓/Enter), at wherever it
|
||||
// currently sits.
|
||||
const pending = ref(null)
|
||||
|
||||
function newPage() {
|
||||
function newPage(list) {
|
||||
if (pending.value) return // one at a time
|
||||
pending.value = {
|
||||
slug: '',
|
||||
@@ -174,7 +175,7 @@ function newPage() {
|
||||
children: [],
|
||||
pending: true,
|
||||
}
|
||||
tree.value.push(pending.value)
|
||||
list.push(pending.value)
|
||||
}
|
||||
|
||||
// Where does the pending row currently sit? -> {parentPath, list, index}.
|
||||
@@ -431,6 +432,7 @@ provide('structureHandlers', {
|
||||
addContent,
|
||||
commitPending,
|
||||
discardPending,
|
||||
newPage,
|
||||
})
|
||||
|
||||
// --- Banner editing ------------------------------------------------------
|
||||
@@ -644,12 +646,6 @@ onUnmounted(() => {
|
||||
|
||||
<section class="block structure">
|
||||
<StructureTree :nodes="tree" />
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
title="new page — drag the new row into place, then fill in title and slug"
|
||||
@click="newPage"
|
||||
>➕</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -774,21 +770,4 @@ onUnmounted(() => {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Icon buttons (➕) keep the emoji's own color, no button chrome. */
|
||||
.structure .add {
|
||||
align-self: flex-start; /* don't stretch to the block's full width */
|
||||
margin-top: 0.3rem;
|
||||
margin-left: 1.2em; /* align with the row titles, past the drag handle */
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.structure .add:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
// the slug commits on blur/Enter since it renames the path, moving the
|
||||
// whole subtree. Nodes without content are category labels that redirect
|
||||
// to their first child; the ➕ on their row gives them a landing page.
|
||||
// The ➕ in the panel header adds a *pending* row: a local-only item that
|
||||
// can be dragged into place before its title/slug are filled in, and is
|
||||
// persisted to the server only on commit (✓/Enter, Esc discards).
|
||||
// Every non-empty list (and the root list) ends with a ➕ footer row:
|
||||
// clicking it adds a *pending* row (a local-only item persisted to the
|
||||
// server only on commit, ✓/Enter, Esc discards) at the end of that list,
|
||||
// and while dragging it doubles as the list's "end of list" drop target.
|
||||
// Leaf pages have no ➕ row of their own, but every row's child list is a
|
||||
// drop target: its (invisible) container box reaches up over the bottom of
|
||||
// its row, so dropping ON a row makes it a child (first position), while
|
||||
// dropping on the row's top edge inserts a sibling before it. See the
|
||||
// `.node > .treelist` style.
|
||||
// The front page is the root row with an empty slug: renaming it away
|
||||
// leaves no front page, and giving another top-level row the empty slug
|
||||
// makes it the front page. Delete is a two-step inline button (no dialog).
|
||||
@@ -59,8 +65,8 @@ function onMove(evt) {
|
||||
return true
|
||||
}
|
||||
|
||||
// While dragging, reveal empty child lists as drop zones (style.css) so a
|
||||
// page can be moved under a childless page.
|
||||
// While dragging, the child-list overlap strips become hit-testable (see
|
||||
// styles). Purely functional — nothing is shown or resized.
|
||||
function onStart() {
|
||||
document.body.classList.add('tree-dragging')
|
||||
}
|
||||
@@ -85,12 +91,12 @@ function onEnd() {
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="node">
|
||||
<!-- Indentation is row padding, not a container margin, so the
|
||||
slug and action columns stay aligned across nesting levels. -->
|
||||
<!-- Indentation is structural: each nested treelist margin-indents
|
||||
itself relative to its parent (see styles), so a dragged row
|
||||
previews its whole subtree at the target list's depth. -->
|
||||
<div
|
||||
class="row"
|
||||
:class="{ current: element.path === handlers.current() }"
|
||||
:style="depth ? { paddingLeft: `${depth * 1.1}rem` } : null"
|
||||
>
|
||||
<span class="drag" title="drag to reorder/move">⠿</span>
|
||||
<template v-if="element.pending">
|
||||
@@ -161,26 +167,77 @@ function onEnd() {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Non-draggable footer (vuedraggable slot): a ➕ row at the end of
|
||||
the list. Clicking adds a pending page at this level; while
|
||||
dragging it is the list's "end of list" drop target. Only shown
|
||||
where the list has items (or at the root, to add the first page). -->
|
||||
<template #footer>
|
||||
<div v-if="nodes.length || !depth" class="add-row">
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
title="new page here — fill in title and slug, then drag into place if needed"
|
||||
@click="handlers.newPage(nodes)"
|
||||
>➕</button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.treelist {
|
||||
min-height: 0;
|
||||
/* Flex column so the ➕ footer can render last via `order` even when
|
||||
Sortable's end-of-list insertion appends the preview after it in the
|
||||
DOM (order only affects painting; Sortable's item rect math and
|
||||
vuedraggable's index mapping still see the logical DOM order). */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Child lists overlap the bottom of their own row (below); the overlap
|
||||
strip must not block clicks on the row's inputs, so containers are
|
||||
hit-test transparent and only the rows re-enable pointer events. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* While dragging, the overlap strips become hit-testable so a row's child
|
||||
list can be targeted. No visual change. */
|
||||
body.tree-dragging .treelist {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A node's child list indents itself (structural indentation: a dragged
|
||||
row's subtree follows its head to the target depth automatically) and
|
||||
reaches up over the bottom ~60% of its row: the negative top margin
|
||||
pulls the container up, the equal padding pushes its children back
|
||||
down — zero layout or visual effect, but while dragging Sortable sees
|
||||
the lower part of the row as inside the child list and natively inserts
|
||||
there as the first child (a leaf's empty child list becomes a sublist
|
||||
this way). The exposed top strip of the row remains the parent list's
|
||||
target = sibling before this row. */
|
||||
.node > .treelist {
|
||||
margin-top: -0.95rem;
|
||||
padding-top: 0.95rem;
|
||||
margin-left: 1.1rem;
|
||||
}
|
||||
|
||||
.node {
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
/* Grid rows: handle / title / slug / actions line up as columns. Rows are
|
||||
full width at every level (indentation is row padding) and the slug and
|
||||
action columns are fixed-width, so they align across nesting levels. */
|
||||
/* Grid rows: handle / title / slug / actions line up as columns within a
|
||||
level (nesting indents the whole list container, so columns align per
|
||||
level, not across levels). */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
/* Vertical spacing widens the drop zones: the exposed top strip is the
|
||||
"sibling before" target, the overlapped bottom is "child of". */
|
||||
padding-top: 0.15rem;
|
||||
padding-bottom: 0.15rem;
|
||||
/* Rows (and the ➕ row) stay clickable despite .treelist's pointer-events: none. */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.row.current .title-edit {
|
||||
@@ -263,4 +320,28 @@ function onEnd() {
|
||||
.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The ➕ footer row: subtle always-visible "add here" button at the end of
|
||||
a list; while dragging it is the list's "end of list" drop target. */
|
||||
.add-row {
|
||||
min-height: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto; /* see .row */
|
||||
order: 1; /* always render after the rows, even mid-drag (see .treelist) */
|
||||
}
|
||||
|
||||
.add {
|
||||
margin-left: 1.2em; /* align with the row titles, past the drag handle */
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.add:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -191,14 +191,6 @@ body.editing #sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
/* Site structure tree: while dragging, empty child lists appear as drop
|
||||
zones so a page can be moved under a childless page. */
|
||||
body.tree-dragging .treelist:empty {
|
||||
min-height: 1.2rem;
|
||||
outline: 1px dashed var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* The banner's own pen: opens the site editor (banner + structure).
|
||||
Qualified with `button` to beat the later .edit-link rule's left offset
|
||||
(both classes apply to the same element). */
|
||||
@@ -211,15 +203,10 @@ button.banner-edit-link {
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.banner-edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
grid-column: 1;
|
||||
/* Pinned to the page's left edge (not the article's) and kept in view
|
||||
|
||||
Reference in New Issue
Block a user