From b39f1da094eca045cfd44fa6af88acbd45d92898 Mon Sep 17 00:00:00 2001 From: cusable <47198998+cusable@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:57:00 -0500 Subject: [PATCH 1/3] fix: keep typed boundary spaces across saves so the Content caret stays put MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a word then a space sent the caret to the start of the line. The Content field emits its text and remembers the html it emitted; every live save echoes back through the parser, and the echo must come back identical or the field's sync rewrites the contentEditable mid-keystroke, resetting the caret to position 0. The echo differed exactly when a run's boundary gained a space, because the two sides disagreed on where boundary whitespace lives. The parse keeps exactly one space where the source had any (collapseText) — a space typed after a word IS the value. The serializer trimmed it anyway on edited inline runs written on one line: inlineString(...).trim(). A text node on a line of its own may trim — the file's indent carries the boundary whitespace back in on reparse (serializeNodeText) — but a one-line run has nothing else to hold it, so parse∘serialize was lossy and the field saw its own edit come back different. The trim predates the boundary-keeping rule; 0.1.33 made it visible by installing the re-parsed file on every save ack, and 0.1.34's id adoption fixed the focus loss on top of it, leaving the caret jump as the remaining symptom (verified: emitted 'hello ' -> written '

hello

' -> echo 'hello'). The serializer now writes the run's boundary spaces, and the Content field canonicalizes DOM-read text with the parser's own rule (runs of whitespace squeeze to one space, one space kept at either boundary) so multi-space keystrokes converge immediately instead of mismatching once. The browser renders whitespace runs collapsed, so the field shows the same thing either way. Tested by a roundtrip section asserting an edited run keeps its boundary spaces and saves twice identically, and renderer-rich cases for the canonicalization including text around an expression chip. Gate: 153/153. --- electron/astroParser.ts | 11 +++++++- src/ui/richContentModel.ts | 18 ++++++++++++-- test/renderer-rich.js | 19 ++++++++++++++ test/roundtrip.test.js | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/electron/astroParser.ts b/electron/astroParser.ts index 6189338b..d7231c40 100644 --- a/electron/astroParser.ts +++ b/electron/astroParser.ts @@ -3469,7 +3469,16 @@ function serializeNodeElement( ); return; } - openTag(`>${inlineString(node.children).trim()}${closeTag}`); + // The run's boundary spaces are content, not layout. A text node on a + // line of its own can trim them — the file's indent hands the boundary + // whitespace back on reparse (see serializeNodeText) — but a run written + // on one line has nothing but the value itself to hold them, and the + // parse keeps exactly one space where the source had any (collapseText). + // Trimming here made parse∘serialize lossy, so a word typed followed by + // a space came back from the save without it and the Content field, + // seeing its own edit echo back different, reset the caret to the start + // of the line. + openTag(`>${inlineString(node.children)}${closeTag}`); return; } if (node.children.length === 0) { diff --git a/src/ui/richContentModel.ts b/src/ui/richContentModel.ts index 4ec1ceea..a687e612 100644 --- a/src/ui/richContentModel.ts +++ b/src/ui/richContentModel.ts @@ -206,6 +206,20 @@ function richReadDOM(element: Element, budget: Budget, depth: number): InlineNod } return out; } +// The text the model holds is the text the parser will hold: runs of +// whitespace squeeze to one space, with one space kept at either boundary. +// The browser renders whitespace runs collapsed anyway, so the field shows +// the same thing — but every save echoes back through the parser, whose text +// rule (collapseText in electron/astroParser.ts) is exactly this, and +// emitting anything else made the echoed value come back different from what +// the field had emitted, resetting the caret mid-word. +function canonicalText(raw: string): string { + return ( + (/^\s/.test(raw) ? ' ' : '') + + raw.replace(/\s+/g, ' ').trim() + + (/\s$/.test(raw) ? ' ' : '') + ); +} function richReadText(raw: string): InlineNode[] { assert(raw.length <= LIMITS.nodeValueCharsMax, 'RichContent: text limit exceeded'); const out: InlineNode[] = []; @@ -213,14 +227,14 @@ function richReadText(raw: string): InlineNode[] { let last = 0; for (const match of raw.matchAll(expression)) { if (match.index > last) { - out.push({ kind: 'text', value: raw.slice(last, match.index) }); + out.push({ kind: 'text', value: canonicalText(raw.slice(last, match.index)) }); } out.push({ kind: 'expr', value: match[0] }); last = match.index + match[0].length; assert(out.length <= LIMITS.treeNodesMax, 'RichContent: text expression limit exceeded'); } if (last < raw.length) { - out.push({ kind: 'text', value: raw.slice(last) }); + out.push({ kind: 'text', value: canonicalText(raw.slice(last)) }); } return out; } diff --git a/test/renderer-rich.js b/test/renderer-rich.js index ad3b84c5..6b82b83c 100644 --- a/test/renderer-rich.js +++ b/test/renderer-rich.js @@ -48,5 +48,24 @@ for (let depth = 0; depth < 66; depth++) { parent = child; } assert.throws(() => domToNodes(host), /DOM depth limit exceeded/); + +// Text the field emits is the text the parser will hold: whitespace runs +// squeeze to one space with one space kept at either boundary. The save echo +// comes back through the parser's own text rule (collapseText in +// electron/astroParser.ts), so anything looser made the echoed value differ +// from the last emission and the field's sync reset the caret mid-word. +host.textContent = 'hello '; +assert.deepEqual(domToNodes(host), [{ kind: 'text', value: 'hello ' }]); +host.textContent = ' hello'; +assert.deepEqual(domToNodes(host), [{ kind: 'text', value: ' hello' }]); +host.textContent = 'a\n b'; +assert.deepEqual(domToNodes(host), [{ kind: 'text', value: 'a b' }]); +host.innerHTML = 'hi {x} there'; +assert.deepEqual(domToNodes(host), [ + { kind: 'text', value: 'hi ' }, + { kind: 'expr', value: '{x}' }, + { kind: 'text', value: ' there' }, +]); + dom.window.close(); console.log('renderer-rich: inline round trips, invalid shapes, and traversal limits passed'); diff --git a/test/roundtrip.test.js b/test/roundtrip.test.js index 2d477d30..fb4c44ab 100644 --- a/test/roundtrip.test.js +++ b/test/roundtrip.test.js @@ -179,6 +179,57 @@ describe('serialization is idempotent', () => { } }); +// --------------------------------------------------------------------------- +// 4b. An edited inline run keeps its boundary spaces +// --------------------------------------------------------------------------- +// +// The Content field is where words get typed, and the keystroke that ends a +// word is a space: the field emits "hello ", the model serializes it, and the +// parse must hand back the same value — parse∘serialize is the field's save +// echo, and an echo that comes back different resets the caret mid-word. A +// text node on a line of its own may trim its boundary spaces (the file's +// indent carries them back in); a run on one line has nothing else to hold +// them, so the serializer must not strip them. + +describe('an edited inline run keeps its boundary spaces', () => { + const cases = [ + ['trailing', '

hello

', 'hello '], + ['leading', '

hello

', ' hello'], + ['both ends of a run after an edit', '

hi

', ' hello '], + ]; + + for (const [name, source, editedValue] of cases) { + test(name, () => { + const first = parsePage(source); + assert.ok(first.editable, 'fixture stopped parsing as editable'); + const heading = first.model.nodes[0]; + assert.ok(heading, 'fixture has a top-level node'); + const text = heading.children?.[0]; + assert.ok(text && text.kind === 'text', 'fixture starts with a text child'); + + // The edit the Content field makes: the value changes under the same + // tree, the way setNodeInline's single text child lands. + text.value = editedValue; + text.source = undefined; + const once = serializePage(first.model); + + const second = parsePage(once); + assert.ok(second.editable, 'edited output no longer parses as editable'); + const echoed = second.model.nodes[0]?.children?.[0]; + assert.ok(echoed && echoed.kind === 'text', 'edited run lost its text child'); + assert.equal( + echoed.value, + editedValue, + `the save echo lost the boundary space of ${JSON.stringify(editedValue)}` + ); + + // And the round trip is stable: the next save changes nothing. + const twice = serializePage(second.model); + assert.equal(twice, once, 'saving twice re-trimmed the boundary space'); + }); + } +}); + // --------------------------------------------------------------------------- // 5. Locality — one edit, one line // --------------------------------------------------------------------------- From 039dc5b545519d2453d21c040b50e372f403e552 Mon Sep 17 00:00:00 2001 From: cusable <47198998+cusable@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:57:06 -0500 Subject: [PATCH 2/3] v0.1.35 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1de861ed..16bb4e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacki", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacki", - "version": "0.1.34", + "version": "0.1.35", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index de3a650f..f8d2b526 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stacki", "productName": "Stacki", - "version": "0.1.34", + "version": "0.1.35", "description": "Stacki — a visual builder for Astro projects — pages, layouts, components, props, live preview, and GitHub publishing.", "main": "dist/electron/main.js", "author": "Timothy Ricks ", From 33b3e2f170b924905fc1656d9c919e7bca8b5152 Mon Sep 17 00:00:00 2001 From: LilianBarbe <110779804+LilianBarbe@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:25:43 +0200 Subject: [PATCH 3/3] fix(navigator): keep the drop marker until the drag lands, leaves or is cancelled The panel body cleared the drop target on every dragleave, including moves between two of its own descendants, so the insertion line flickered and vanished while hovering the marker or a row. It now clears only when the pointer leaves the panel, hovers blank space, or the drag ends. The marker is absolutely positioned and ignores the pointer, so it no longer shifts the gap or becomes a drag target itself. Also, as separate UI choices: - the native drag ghost is replaced by a transparent image, so only the drop line shows where the node will land; - starting a drag selects the dragged row. Co-Authored-By: Claude Opus 5.5 --- src/panels/StructurePanel.tsx | 9 +++++++- src/panels/StructureTree.tsx | 12 ++++++++++- src/styles.css | 8 +++++-- test/navigator.js | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/panels/StructurePanel.tsx b/src/panels/StructurePanel.tsx index 4b3fa0ed..2d4007d2 100644 --- a/src/panels/StructurePanel.tsx +++ b/src/panels/StructurePanel.tsx @@ -433,7 +433,13 @@ function NavigatorBody({ }) { const nodes = props.pageState.model.nodes; return ( -
state.setDropTarget(null)}> +
state.setDropTarget(null)} + onDragLeave={(event) => { + if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) { + state.setDropTarget(null); + } + }}> {nodes.length === 0 && } @@ -482,6 +488,7 @@ function EmptyDropTarget({ onDragOver={(event) => { if (isDndPayload(event)) { event.preventDefault(); + event.stopPropagation(); state.setDropTarget({ kind: 'gap', ...location }); } }} diff --git a/src/panels/StructureTree.tsx b/src/panels/StructureTree.tsx index ecd7876e..5dbda7be 100644 --- a/src/panels/StructureTree.tsx +++ b/src/panels/StructureTree.tsx @@ -63,7 +63,15 @@ interface NodeListProps extends StructureTreeContext { const ANNOTATABLE = new Set(['element', 'component']); +// Decode once so native drag feedback does not obscure the drop target. +let blankDragImage: HTMLImageElement | undefined; export function NodeList({ nodes, parentId, depth, ...context }: NodeListProps) { + useEffect(() => { + if (!blankDragImage) { + blankDragImage = document.createElement('img'); + blankDragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + } + }, []); const { noteFor, folded } = foldedNotes(nodes); return ( <> @@ -299,6 +307,8 @@ function treeRowHandlers(props: TreeRowProps) { event.stopPropagation(); event.dataTransfer.setData('avb/node', node.id); event.dataTransfer.effectAllowed = 'move'; + if (blankDragImage) { event.dataTransfer.setDragImage(blankDragImage, 0, 0); } + props.onSelect(node.id); const tag = 'name' in node ? node.name : undefined; setDrag({ kind: 'node', @@ -307,7 +317,7 @@ function treeRowHandlers(props: TreeRowProps) { ...(tag === undefined ? {} : { tag }), }); }, - onDragEnd: clearDrag, + onDragEnd: () => { clearDrag(); props.setDropTarget(null); }, onDragOver: (event: React.DragEvent): void => { if (props.canHostChildren && acceptsDrag(node) && props.isDndPayload(event)) { event.preventDefault(); diff --git a/src/styles.css b/src/styles.css index 02fa6301..61b033a1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2380,14 +2380,18 @@ body > .embed-editor_provenance { gaps of every ancestor level are adjacent in the DOM — collapse all but the first so they don't stack into a large blank strip (each stays a distinct drop target for its own nesting level). */ -.nav-gap { height: 7px; } +.nav-gap { position: relative; height: 7px; } .nav-gap + .nav-gap { height: 2px; } .drop-indicator { + /* The marker must not move the gap or become a new drag target. */ + position: absolute; + inset: 50% 0 auto; + transform: translateY(-50%); + pointer-events: none; height: 2px; border-radius: 1px; background: var(--accent); - margin: 2px 0; box-shadow: 0 0 6px rgba(0, 153, 255, 0.6); } .drop-zone-empty { diff --git a/test/navigator.js b/test/navigator.js index 3fb89c7e..0eeebdbf 100644 --- a/test/navigator.js +++ b/test/navigator.js @@ -269,6 +269,45 @@ const WORDS = el('words', 'p', [{ id: 'w-text', kind: 'expr', value: '{heading}' ); } + // Moving between descendants must not erase the destination between two + // dragover events. Leaving the panel, hovering blank space, and cancelling + // a drag must still remove it. + { + const source = rowFor('hero-command'); + const gap = rowFor('if-ternary').previousElementSibling; + const data = new Map(); + const dataTransfer = { + types: ['avb/node'], + setData: (type, value) => data.set(type, value), + getData: (type) => data.get(type) || '', + setDragImage: () => {}, + }; + const fire = (element, type, relatedTarget = null) => act(async () => { + element.dispatchEvent(Object.assign( + new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }), + { dataTransfer } + )); + }); + await fire(source, 'dragstart'); + check('dragging selects the source row', selected.at(-1) === 'hero-command'); + check('dragging keeps the source in its original place', rowFor('hero-command') === source); + await fire(gap, 'dragover'); + check('a gap shows the insertion marker', !!gap.querySelector('.drop-indicator')); + await fire(gap, 'dragleave', gap.querySelector('.drop-indicator')); + check('entering the marker does not erase it', !!gap.querySelector('.drop-indicator')); + await fire(rowFor('if-and'), 'dragover'); + await fire(gap, 'dragleave', rowFor('if-and').querySelector('.label')); + check('entering a row preserves the new child destination', rowFor('if-and').style.borderColor !== ''); + await fire(rowFor('if-and'), 'dragleave', document.body); + check('leaving the panel clears the child destination', rowFor('if-and').style.borderColor === ''); + await fire(gap, 'dragover'); + await fire(container.querySelector('.panel-body'), 'dragover'); + check('blank space clears the previous marker', !container.querySelector('.drop-indicator')); + await fire(gap, 'dragover'); + await fire(source, 'dragend'); + check('cancelling clears the insertion marker', !container.querySelector('.drop-indicator')); + } + // --- arrow keys ------------------------------------------------------------- // They walk the tree that is drawn: a row nobody can see is not somewhere the // selection can land.