Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"test:bridge": "node test/bridge.js",
"test:outlines": "node test/outlines.js",
"test:navigator": "node test/navigator.js",
"test:navigatoropencode": "node test/navigator-open-code.js",
"test:classattr": "node test/class-attr.js",
"test:gridtracks": "node test/grid-tracks.js",
"test:windowbounds": "node test/window-bounds.js",
Expand Down
42 changes: 21 additions & 21 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ import { toRecord } from '../shared/record';
import { projectRelativePath } from './projectPath.js';
import { currentDesktopPlatform, shortcutLabel } from './shortcutLabel.js';
import { sourceNodeAtOffset } from './codePanelModel.js'
import {
codeWindowFor,
FRONTMATTER_SUBJECT,
type CodeSubject,
type FrontmatterSubject,
} from './codeWindowTarget';
import {
cloneEditorModel,
findEditorNodeById as findNodeById,
Expand Down Expand Up @@ -3592,7 +3598,7 @@ export default function App() {
};

const selectedNode:
| EditorNode | { readonly id: 'frontmatter'; readonly kind: 'frontmatter'; readonly value: string } | null =
| EditorNode | (FrontmatterSubject & { readonly value: string }) | null =
model && selectedId
? selectedId === 'frontmatter'
? { id: 'frontmatter', kind: 'frontmatter', value: frontmatterCode }
Expand Down Expand Up @@ -3944,29 +3950,22 @@ export default function App() {
? codeWinNode.inner
: null;

// Returns whether the selection actually has a code editor, so the Enter
// Returns whether the subject actually has a code editor, so the Enter
// shortcut below knows whether it handled the key.
const openCodeWindow = () => {
if (!selectedNode) {
const openCodeWindowFor = (subject: CodeSubject | null): boolean => {
const codeWindow = subject === null ? undefined : codeWindowFor(subject);
if (codeWindow === undefined) {
return false;
}
if (selectedNode.kind === 'frontmatter') {
setCodeWin({
targetId: 'frontmatter',
title: 'Frontmatter',
language: 'javascript',
});
return true;
}
if (selectedNode.kind === 'raw') {
setCodeWin({
targetId: selectedNode.id,
title: `<${selectedNode.name}>`,
language: selectedNode.name === 'style' ? 'css' : 'javascript',
});
return true;
}
return false;
setCodeWin(codeWindow);
return true;
};
const openCodeWindow = (): boolean => openCodeWindowFor(selectedNode);
// A navigator double-click names its row instead of reading `selectedNode`
// (see `openCode` in StructureTree). An id the tree no longer holds, a row
// removed between the click and the render, opens nothing.
const openCodeWindowById = (id: string): void => {
openCodeWindowFor(id === 'frontmatter' ? FRONTMATTER_SUBJECT : tree.node(id));
};
// Read by the keydown effect, which is set up long before this exists.
openCodeWindowRef.current = openCodeWindow;
Expand Down Expand Up @@ -4619,6 +4618,7 @@ export default function App() {
onSelect={setSelectedId}
onHoverNode={setHoverNodeId}
onOpenComponent={(name, id) => openComponent(name, pathFor(id))}
onOpenCode={openCodeWindowById}
onChangeLayout={changeLayout}
onDropComponent={addComponent}
onMoveNode={moveNode}
Expand Down
38 changes: 38 additions & 0 deletions src/codeWindowTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { assert } from '../shared/assert';
import type { CodeWindowState, EditorNode } from './appTypes';

/** The page frontmatter: selectable like a node, but it is not one. */
export interface FrontmatterSubject {
readonly id: 'frontmatter';
readonly kind: 'frontmatter';
}

export type CodeSubject = EditorNode | FrontmatterSubject;

export const FRONTMATTER_SUBJECT: FrontmatterSubject = { id: 'frontmatter', kind: 'frontmatter' };

// What the floating code editor opens for a subject. Enter, the "Edit code"
// button and a navigator double-click all ask this one rule, so they cannot
// disagree on the title or the language. Markup has no editor of its own: the
// answer is `undefined`, and Enter is left unhandled.
export function codeWindowFor(subject: CodeSubject): CodeWindowState | undefined {
if (subject.kind === 'frontmatter') {
return { targetId: 'frontmatter', title: 'Frontmatter', language: 'javascript' };
}
if (subject.kind === 'raw') {
assert(subject.id.length > 0, 'raw node has an id');
// The parser matches the tag case-insensitively but keeps it as written,
// so `<STYLE>` arrives here as "STYLE".
const language = RAW_CODE_LANGUAGES[subject.name.toLowerCase()];
assert(language !== undefined, `raw node <${subject.name}> has no code language`);
return { targetId: subject.id, title: `<${subject.name}>`, language };
}
return undefined;
}

// The parser keeps exactly these elements raw (`RAW_ELEMENTS` in
// electron/astroParser.ts), so they are the only ones with a code language.
const RAW_CODE_LANGUAGES: Readonly<Record<string, string>> = {
script: 'javascript',
style: 'css',
};
4 changes: 4 additions & 0 deletions src/panels/StructurePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface StructurePanelProps {
readonly onSelect: (id: string) => void;
readonly onHoverNode?: (id: string | null) => void;
readonly onOpenComponent?: (name: string, id: string) => void;
/** A row holding code (frontmatter, <style>, <script>) was double-clicked. */
readonly onOpenCode?: (id: string) => void;
readonly onChangeLayout: (name: string) => void;
readonly onDropComponent: (name: string, target: DropLocation) => void;
readonly onMoveNode: (id: string, target: DropLocation) => void;
Expand Down Expand Up @@ -342,6 +344,7 @@ function treeContextOf(props: EditableProps, state: TreeState): StructureTreeCon
onSelect: props.onSelect,
...(props.onHoverNode === undefined ? {} : { onHoverNode: props.onHoverNode }),
...(props.onOpenComponent === undefined ? {} : { onOpenComponent: props.onOpenComponent }),
...(props.onOpenCode === undefined ? {} : { onOpenCode: props.onOpenCode }),
toggleCollapse: (node) => {
state.setToggled((previous) =>
new Map(previous).set(node.id, !collapsedState(node, previous)),
Expand Down Expand Up @@ -445,6 +448,7 @@ function FrontmatterRow({ props }: { readonly props: EditableProps }) {
className={`structure-node frontmatter-node ${props.selectedId === 'frontmatter' ? 'selected' : ''}`}
style={{ paddingLeft: 6 }}
onClick={() => props.onSelect('frontmatter')}
onDoubleClick={() => props.onOpenCode?.('frontmatter')}
>
<span className="drag-handle" style={{ visibility: 'hidden' }}>
<DragIcon size={11} />
Expand Down
19 changes: 18 additions & 1 deletion src/panels/StructureTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface StructureTreeContext {
readonly onSelect: (id: string) => void;
readonly onHoverNode?: (id: string | null) => void;
readonly onOpenComponent?: (name: string, id: string) => void;
readonly onOpenCode?: (id: string) => void;
readonly toggleCollapse: (node: NavigatorNode) => void;
readonly openContextMenu: (left: number, top: number, nodeId: string) => void;
}
Expand Down Expand Up @@ -194,6 +195,7 @@ function treeContextFromRow(props: TreeNodeProps): StructureTreeContext {
onSelect: props.onSelect,
...(props.onHoverNode === undefined ? {} : { onHoverNode: props.onHoverNode }),
...(props.onOpenComponent === undefined ? {} : { onOpenComponent: props.onOpenComponent }),
...(props.onOpenCode === undefined ? {} : { onOpenCode: props.onOpenCode }),
toggleCollapse: props.toggleCollapse,
openContextMenu: props.openContextMenu,
};
Expand Down Expand Up @@ -323,7 +325,12 @@ function treeRowHandlers(props: TreeRowProps) {
props.onSelect(node.id);
},
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>): void => {
openComponent(event, props);
// A <script> or <style> is code, not markup: it opens in the code editor.
if (node.kind === 'raw') {
openCode(event, props);
} else {
openComponent(event, props);
}
},
onMouseEnter: (): void => props.onHoverNode?.(node.id),
onMouseLeave: (): void => props.onHoverNode?.(null),
Expand All @@ -335,6 +342,16 @@ function treeRowHandlers(props: TreeRowProps) {
};
}

// The row's own id is passed on: its click has only just asked for the
// selection, which still names the previous row when the double-click lands.
function openCode(event: React.MouseEvent<HTMLDivElement>, props: TreeRowProps): void {
if (!props.onOpenCode) {
return;
}
event.stopPropagation();
props.onOpenCode(props.node.id);
}

function openComponent(event: React.MouseEvent<HTMLDivElement>, props: TreeRowProps): void {
const { node } = props;
if (!props.component || (node.kind === 'component' && node.astroAsset)) {
Expand Down
169 changes: 169 additions & 0 deletions test/navigator-open-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Goal: a double-click on a navigator row that holds code opens that code in the
// floating editor — the one Enter and the "Edit code" button already open.
//
// Methodology: two layers, each checked on its own.
// 1. `codeWindowFor`, the pure rule shared by Enter, the button and the
// double-click: which subject opens which editor, and what has none.
// 2. The navigator itself, rendered in JSDOM: a double-click on the Frontmatter,
// a <style> or a <script> row reports that row's id — not the selection,
// which the row's own click has only just asked for — while element and
// component rows keep the behaviour they had.
//
// node test/navigator-open-code.js

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const esbuild = require('esbuild');

const buildDir = path.join(__dirname, '..', 'node_modules', '.stacki-test');
const settle = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));

const bundle = async (entry, name) => {
const outfile = path.join(buildDir, name);
await esbuild.build({
entryPoints: [path.join(__dirname, '..', 'src', ...entry)],
outfile,
bundle: true,
format: 'cjs',
platform: 'node',
jsx: 'automatic',
external: ['react', 'react-dom', 'react/jsx-runtime'],
logLevel: 'silent',
});
return require(outfile);
};

const raw = (id, name) => ({ id, kind: 'raw', name, inner: 'a {}' });
const element = (id, name, children = []) => ({
id,
kind: 'element',
name,
props: { class: id },
children,
});
const component = (id, name) => ({ id, kind: 'component', name, props: {}, children: [] });

function checkCodeWindowRule({ codeWindowFor }) {
assert.deepEqual(codeWindowFor({ id: 'frontmatter', kind: 'frontmatter' }), {
targetId: 'frontmatter',
title: 'Frontmatter',
language: 'javascript',
});
assert.deepEqual(codeWindowFor(raw('s1', 'style')), {
targetId: 's1',
title: '<style>',
language: 'css',
});
assert.deepEqual(codeWindowFor(raw('j1', 'script')), {
targetId: 'j1',
title: '<script>',
language: 'javascript',
});
// The parser keeps a tag as written, so a capitalised one must still open.
assert.deepEqual(codeWindowFor(raw('s2', 'STYLE')), {
targetId: 's2',
title: '<STYLE>',
language: 'css',
});
// Markup has no code editor of its own: Enter on it must stay unhandled.
assert.equal(codeWindowFor(element('e1', 'div')), undefined);
assert.equal(codeWindowFor(component('c1', 'Card')), undefined);
// The parser only ever makes <style> and <script> raw. Any other name is a
// broken invariant, not a language to guess.
assert.throws(
() => codeWindowFor(raw('t1', 'template')),
/Assertion failed: raw node <template> has no code language/,
);
assert.throws(() => codeWindowFor(raw('', 'style')), /Assertion failed: raw node has an id/);
}

function installDom() {
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
global.Element = dom.window.Element;
global.HTMLElement = dom.window.HTMLElement;
global.Node = dom.window.Node;
global.IS_REACT_ACT_ENVIRONMENT = true;
dom.window.Element.prototype.scrollIntoView = function scrollIntoView() {};
return dom;
}

async function checkNavigator(StructurePanel) {
const dom = installDom();
const React = require('react');
const { act } = require('react');
const { createRoot } = require('react-dom/client');
const container = dom.window.document.getElementById('root');
const reactRoot = createRoot(container);
const opened = { code: [], component: [] };
const nodes = [
element('page', 'main', [raw('s1', 'style'), element('e1', 'div'), component('c1', 'Card')]),
raw('j1', 'script'),
];
await act(async () => {
reactRoot.render(
React.createElement(StructurePanel, {
pageState: { editable: true, model: { nodes, imports: [] } },
layouts: [],
currentLayoutName: '',
selectedId: null,
onSelect: () => {},
onOpenCode: (id) => opened.code.push(id),
onOpenComponent: (name, id) => opened.component.push(`${name}:${id}`),
onChangeLayout: () => {},
onDropComponent: () => {},
onMoveNode: () => {},
onRemoveNode: () => {},
onCopyNode: () => {},
onDuplicateNode: () => {},
onPasteNode: () => {},
onRawChange: () => {},
hasClipboard: false,
}),
);
await settle(20);
});
// Everything starts collapsed; the header button opens it all.
await act(async () => {
container.querySelector('.panel-header button').click();
await settle(20);
});
const doubleClick = async (row) => {
assert.ok(row, 'the row to double-click is drawn');
opened.code.length = 0;
opened.component.length = 0;
await act(async () => {
row.dispatchEvent(new dom.window.MouseEvent('dblclick', { bubbles: true }));
await settle(10);
});
return { code: [...opened.code], component: [...opened.component] };
};
const rowFor = (id) => container.querySelector(`.structure-node[data-node-id="${id}"]`);

const frontmatter = await doubleClick(container.querySelector('.frontmatter-node'));
assert.deepEqual(frontmatter, { code: ['frontmatter'], component: [] });
assert.deepEqual(await doubleClick(rowFor('s1')), { code: ['s1'], component: [] });
assert.deepEqual(await doubleClick(rowFor('j1')), { code: ['j1'], component: [] });
// Rows without code keep what they did before: nothing, or open the component.
assert.deepEqual(await doubleClick(rowFor('e1')), { code: [], component: [] });
assert.deepEqual(await doubleClick(rowFor('c1')), { code: [], component: ['Card:c1'] });
await act(async () => reactRoot.unmount());
}

(async () => {
fs.mkdirSync(buildDir, { recursive: true });
checkCodeWindowRule(await bundle(['codeWindowTarget.ts'], 'code-window-target.cjs'));
const { default: StructurePanel } = await bundle(
['panels', 'StructurePanel.tsx'],
'navigator-open-code.cjs',
);
await checkNavigator(StructurePanel);
console.log('navigator-open-code: all checks passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});