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/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 ", 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/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/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. 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 // ---------------------------------------------------------------------------