Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion electron/astroParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <hello@timothyricks.com>",
Expand Down
9 changes: 8 additions & 1 deletion src/panels/StructurePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,13 @@ function NavigatorBody({
}) {
const nodes = props.pageState.model.nodes;
return (
<div className="panel-body" ref={state.bodyRef} onDragLeave={() => state.setDropTarget(null)}>
<div className="panel-body" ref={state.bodyRef}
onDragOver={() => state.setDropTarget(null)}
onDragLeave={(event) => {
if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
state.setDropTarget(null);
}
}}>
<FrontmatterRow props={props} />
<NodeList nodes={nodes} parentId={null} depth={0} {...treeContext} />
{nodes.length === 0 && <EmptyDropTarget props={props} state={state} />}
Expand Down Expand Up @@ -482,6 +488,7 @@ function EmptyDropTarget({
onDragOver={(event) => {
if (isDndPayload(event)) {
event.preventDefault();
event.stopPropagation();
state.setDropTarget({ kind: 'gap', ...location });
}
}}
Expand Down
12 changes: 11 additions & 1 deletion src/panels/StructureTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -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',
Expand All @@ -307,7 +317,7 @@ function treeRowHandlers(props: TreeRowProps) {
...(tag === undefined ? {} : { tag }),
});
},
onDragEnd: clearDrag,
onDragEnd: () => { clearDrag(); props.setDropTarget(null); },
onDragOver: (event: React.DragEvent<HTMLDivElement>): void => {
if (props.canHostChildren && acceptsDrag(node) && props.isDndPayload(event)) {
event.preventDefault();
Expand Down
8 changes: 6 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions src/ui/richContentModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,35 @@ 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[] = [];
const expression = /\{[^{}]*\}/g;
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;
}
Expand Down
39 changes: 39 additions & 0 deletions test/navigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions test/renderer-rich.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
51 changes: 51 additions & 0 deletions test/roundtrip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<h1>hello </h1>', 'hello '],
['leading', '<h1> hello</h1>', ' hello'],
['both ends of a run after an edit', '<h1>hi</h1>', ' 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
// ---------------------------------------------------------------------------
Expand Down
Loading