/** * The insert panel. * * Two methods coexist, each with its own case: * * Click — inserts at the current selection, the fastest route. Inserting several in a row * is far quicker than dragging. * Drag — a precise drop point with a visible insertion line, matching design-tool * intuition. * * The technical crux of dragging: the canvas is in an iframe, and the parent document does not * receive pointer events over an iframe by default. During a drag the iframe's `pointer-events` * is switched off so the parent document takes every event, and coordinates are converted back * into iframe space by the viewport scale for hit testing (see canvas.js). */ import { useCallback, useRef, useState } from 'react'; import { describeNode } from './nodeLabel'; import { PRIMITIVES, COMPOSITES } from './presets.js'; import { useStoreChange } from './useStore.js'; import { __, sprintf } from './i18n.js'; const DRAG_THRESHOLD = 4; export default function InsertPanel({ store, canvasRef, onInserted, onNotice, cfg }) { useStoreChange(store, ['select', 'structure']); const [tab, setTab] = useState('primitives'); const [drag, setDrag] = useState(null); const dropRef = useRef(null); const components = Object.values(store.getComponents()); /* * The Components tab lists this page's components and, below them, the library components * this page does not use yet. Since 1.7.65 the library is the single source of truth (G36): * a page component with a library row of the same slug is that library component, so it is * listed once — editing it here reaches every page, and a kit update reaches it here. */ const onPage = new Set(components.map((c) => c.id)); const library = (cfg?.library || []).filter((row) => !onPage.has(row.slug)).map((row) => ({ key: `lib:${row.id}`, label: row.name, icon: '⬡', hint: sprintf(/* translators: %s: Category the library component belongs to. */ __('Component library · %s', 'aumframe'), row.category), libraryDef: row.def, librarySlug: row.slug, })); const items = tab === 'primitives' ? PRIMITIVES : tab === 'composites' ? COMPOSITES : [ ...components.map((c) => ({ key: c.id, label: c.name, icon: '◈', hint: sprintf(/* translators: %d: Number of places on this page. */ __('Used in %d places on this page · shared with every page that uses it', 'aumframe'), store.instanceCount(c.id)), componentId: c.id, })), ...library, ]; /* * Tell the user where it will land before they click, and how it will be laid out. * * "Insert into Page" alone is not enough — they insert, find everything stacked full-width, and * conclude the tool can only do that. Naming the container's layout mode is what tells them * "to place things side by side, change the container to horizontal or free". */ const sel = store.getSelection(); const node = sel ? store.getNode(sel) : null; const isContainer = node && ['Frame', 'Repeater', 'Slot'].includes(node.type); const container = isContainer ? node : (node ? store.getNode(store.parentMap()[node.id]) : store.getNode(store.getScene().root)); const layoutOf = (n) => { const st = n?.style || {}; if (st.layout === 'grid') return sprintf(/* translators: %d: Number of columns. */ __('a %d-column grid', 'aumframe'), st.columns || 1); if (st.layout === 'none') return __('free placement', 'aumframe'); return st.direction === 'horizontal' ? __('a horizontal row', 'aumframe') : __('a vertical stack', 'aumframe'); }; const target = !node ? __('the end of the page', 'aumframe') : isContainer ? sprintf(/* translators: %s: Name of the container the element will go into. */ __('inside %s', 'aumframe'), describeNode(store, node, sprintf, __)) : sprintf(/* translators: %s: Name of the element it will be placed after. */ __('after %s', 'aumframe'), describeNode(store, node, sprintf, __)); const arrange = layoutOf(container); const place = useCallback((preset, opts, transform) => { // Components insert as instances rather than copied nodes — a copy stops following the // component's updates let blueprint = (preset.componentId || preset.libraryDef) ? null : preset.build(); /* * A new Heading / Text adopts the page's own most-used h2 / p style (store.textStyles, G32): * the preset's 34px/700 was a generic guess and never matched the kit's ladder, so every * inserted heading looked foreign until the buyer re-typed five numbers. */ if (blueprint && (preset.key === 'heading' || preset.key === 'text') && store.textStyles) { const want = preset.key === 'heading' ? 'h2' : 'p'; const match = store.textStyles().find((p) => p.tag === want); if (match) { blueprint = { ...blueprint, style: { ...blueprint.style, text: structuredClone(match.text) }, ...(Object.keys(match.responsive || {}).length ? { responsive: Object.fromEntries(Object.entries(match.responsive).map(([bp, t]) => [bp, { text: structuredClone(t) }])) } : {}) }; } } if (blueprint && transform) { // A composite preset is {root, nodes}; only the root is adjusted blueprint = blueprint.nodes ? { ...blueprint, nodes: { ...blueprint.nodes, [blueprint.root]: transform(blueprint.nodes[blueprint.root]) } } : transform(blueprint); } const id = preset.libraryDef ? store.insertFromLibrary(preset.libraryDef, { ...(opts || {}), slug: preset.librarySlug }) : preset.componentId ? store.insertInstance(preset.componentId, opts) : store.insert(blueprint, opts); if (!id) { onNotice?.(__('Insert failed.', 'aumframe')); return; } onNotice?.(sprintf(/* translators: %s: Name of the inserted preset. */ __('Inserted %s', 'aumframe'), preset.label)); onInserted?.(id); }, [store, onInserted, onNotice]); /* Drag to insert */ /* * Click and drag take separate paths: * * click -> onClick fires for the mouse and the keyboard. Binding pointerdown alone * would mean activating the button with Enter or Space inserts nothing — * shutting keyboard users out entirely. * drag -> pointerup only past the threshold, with the canvas resolving the drop point. * * A click still follows the pointerup that ends a drag and has to be swallowed, or two get * inserted. */ const suppressClick = useRef(false); const onPointerDown = useCallback((e, preset) => { if (e.button !== 0) return; const startX = e.clientX; const startY = e.clientY; let started = false; const onMove = (ev) => { if (!started) { if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return; started = true; canvasRef?.current?.beginExternalDrag(); document.body.style.cursor = 'copy'; } setDrag({ preset, x: ev.clientX, y: ev.clientY }); dropRef.current = canvasRef?.current?.previewExternalDrop(ev.clientX, ev.clientY) || null; }; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; canvasRef?.current?.endExternalDrag(); if (started) { suppressClick.current = true; const drop = dropRef.current; // Not over a valid position: treat it as if the drag never happened, rather than quietly // appending to the end of the page if (!drop) { onNotice?.(__('That is not a place it can go.', 'aumframe')); } else if (drop.mode === 'free') { // A free container: drop it where the pointer was released, and switch the size from fill // to fit — something placed freely has no reason to fill its parent; what the user wants is // "this big, right here" place(preset, { parentId: drop.containerId }, (node) => ({ ...node, style: { ...node.style, width: { mode: 'fit' }, height: { mode: 'fit', ...(node.style?.height?.min ? { min: node.style.height.min } : {}) }, position: { mode: 'absolute', pins: { left: drop.x, top: drop.y } }, }, })); } else { place(preset, { parentId: drop.containerId, index: drop.index }); } } setDrag(null); dropRef.current = null; }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); }, [canvasRef, place, onNotice]); /** * Insert as a sibling of the selected container rather than inside it. * * Why this is needed: after an insert the new node is selected, so clicking the same primitive * repeatedly nests it layer after layer — three clicks aiming for three side-by-side * containers produce a Russian doll. The drop hint does say "insert into X", but nobody is * reading that line while clicking quickly. * * The default is not changed ("insert into the selected container" is correct in itself, and is * the only way to fill a container); instead there is an explicit modifier: hold ⌥ to insert * as a sibling, stated plainly in the hint. */ const siblingOpts = useCallback(() => { const sid = store.getSelection(); if (!sid) return undefined; const parentId = store.parentMap()[sid]; if (!parentId) return undefined; // The root has no siblings to speak of const siblings = store.getNode(parentId)?.children || []; return { parentId, index: siblings.indexOf(sid) + 1 }; }, [store]); const onClick = useCallback((preset, e) => { if (suppressClick.current) { suppressClick.current = false; return; } place(preset, e?.altKey ? siblingOpts() : undefined); }, [place, siblingOpts]); return (
{__('A composite is just a node tree we assembled for you, not a new type — take it apart however you like once it is in.', 'aumframe')}
)} {tab === 'components' && ({components.length ? __('Instances follow the component. Edit it once and every use changes.', 'aumframe') : library.length ? __('These come from the shared library. To make one of your own, select a container and press “Save as component” above the layer panel.', 'aumframe') : __('No components yet. Select a container and press “Save as component” above the layer panel.', 'aumframe')}
)} {drag && (