From 8753ee4760e22208d3c41bb084f097df4ab190f6 Mon Sep 17 00:00:00 2001 From: LilianBarbe <110779804+LilianBarbe@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:16:08 +0200 Subject: [PATCH] feat(style-panel): a spacing drag sizes and labels the canvas band Dragging a side of the spacing box runs the panel's number through every value on the way, but the band drawn over the page did not follow. Its label came from the hover, which reads the file, so it kept the old value for the whole drag ("2rem" under a number already at 3). Its size came from the page as last measured: the value had to be written, laid out and reported back, a few frames behind and skipping whatever the page had no time to lay out. The drag now tells the canvas its value on the frame the labels change (`labels` and `live` on SpacingHover, for the sides Shift and Alt bring in). The canvas turns it into pixels itself, with the units the page now reports alongside its measurements (em is the element's font, rem the root's, vw and vh the frame), and draws the band that size at once. A percentage, a variable or a calc() keeps the measurement: only the page can size those. An inset (`top` on a positioned element) is labelled but measured. The hover is silent while a drag reports, so a modifier pressed mid-drag cannot put the file's value back on the band. On release the label stays and the size is measured again, which by then is the same number. `units` is optional in the rects message, so a page served by an older preload still parses; its bands are measured, as before. Co-Authored-By: Claude Opus 5.5 --- electron/preload/frameBasics.ts | 2 + electron/preload/frameMeasure.ts | 13 +- src/features/preview/PreviewOverlays.tsx | 29 +-- src/features/preview/previewMessages.ts | 2 + src/features/preview/spacingBands.ts | 61 ++++++ src/features/style/SpacingFill.tsx | 91 ++++++++- src/features/style/SpacingLabel.tsx | 12 +- src/features/style/model/host.ts | 5 + test/renderer/preview/previewMessages.test.js | 4 + test/renderer/preview/spacingLive.test.js | 88 ++++++++ test/renderer/style/spacingDragReport.test.js | 188 ++++++++++++++++++ 11 files changed, 465 insertions(+), 30 deletions(-) create mode 100644 test/renderer/preview/spacingLive.test.js create mode 100644 test/renderer/style/spacingDragReport.test.js diff --git a/electron/preload/frameBasics.ts b/electron/preload/frameBasics.ts index 07cf6c23..f8b56738 100644 --- a/electron/preload/frameBasics.ts +++ b/electron/preload/frameBasics.ts @@ -12,6 +12,8 @@ export type Spacing = { padding: Record; margin: Record; gaps: GapBand[]; + /** What one em, rem, vw and vh is worth in px for the element measured. */ + units: Record; }; export type GapRow = { top: number; bottom: number; items: DOMRect[] }; diff --git a/electron/preload/frameMeasure.ts b/electron/preload/frameMeasure.ts index 7acf46f2..74e98952 100644 --- a/electron/preload/frameMeasure.ts +++ b/electron/preload/frameMeasure.ts @@ -320,7 +320,18 @@ export const spacingForPath = (nodePath: string): (Spacing | undefined)[] => { parseFloat(cs.getPropertyValue(`${kind}-${side}`)) || 0, ]), ); - return { padding: box('padding'), margin: box('margin'), gaps: gapBandsFor(element, cs) }; + // What a length is worth here, so a value being dragged in the panel can + // be drawn in pixels before the page has laid it out (withLiveSpacing in + // the app): em is this element's font, rem the root's, vw and vh the frame. + const rootFont = parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; + const units = { + em: parseFloat(cs.fontSize) || rootFont, + rem: rootFont, + vw: window.innerWidth / 100, + vh: window.innerHeight / 100, + }; + const gaps = gapBandsFor(element, cs); + return { padding: box('padding'), margin: box('margin'), gaps, units }; } catch { // Whatever went wrong measuring one element, the boxes everything else // depends on still have to be reported. diff --git a/src/features/preview/PreviewOverlays.tsx b/src/features/preview/PreviewOverlays.tsx index eb06c541..4c3020cb 100644 --- a/src/features/preview/PreviewOverlays.tsx +++ b/src/features/preview/PreviewOverlays.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { Box } from './outlineBoxes'; import type { PreviewMessage } from './previewMessages'; import { hoverIsSelection, onePerPlace, outlineBoxPath } from './outlineBoxes'; -import { spacingBands } from './spacingBands'; +import { spacingBands, withLiveSpacing } from './spacingBands'; import { BranchIcon, CodeIcon, @@ -32,6 +32,8 @@ export interface SpacingHover { readonly kind: 'padding' | 'margin' | 'gap'; readonly sides?: readonly string[]; readonly labels?: Readonly>; + /** The values a drag is writing, per side: they size the band ahead of the page. */ + readonly live?: Readonly>; } // The measurements as the canvas frame reports them (src/features/preview/previewMessages.ts): a @@ -92,19 +94,18 @@ function SpacingOverlays(props: PreviewOverlaysProps) { const measurements = props.spacing[selectedPath] ?? []; const box = boxes[props.selOcc ?? 0] ?? boxes[0]; const measurement = measurements[props.selOcc ?? 0] ?? measurements[0] ?? undefined; - return spacingBands(box, measurement, spacingHover.kind, spacingHover.sides).map( - (band, index) => ( -
- - {spacingHover.labels?.[band.side] ?? spacingLabel(band)} - -
- ), - ); + const sized = withLiveSpacing(measurement, spacingHover.kind, spacingHover.live); + return spacingBands(box, sized, spacingHover.kind, spacingHover.sides).map((band, index) => ( +
+ + {spacingHover.labels?.[band.side] ?? spacingLabel(band)} + +
+ )); } function NodeOutlines(props: PreviewOverlaysProps) { diff --git a/src/features/preview/previewMessages.ts b/src/features/preview/previewMessages.ts index 925c228b..6efc73cf 100644 --- a/src/features/preview/previewMessages.ts +++ b/src/features/preview/previewMessages.ts @@ -215,10 +215,12 @@ function parseSpacing(input: unknown): Spacing { const padding = optional(parseSides)(value['padding']); const margin = optional(parseSides)(value['margin']); const gaps = optional(list(parseGap))(value['gaps']); + const units = optional(dictionary(nonnegativeFinite))(value['units']); return { ...(padding === undefined ? {} : { padding }), ...(margin === undefined ? {} : { margin }), ...(gaps === undefined ? {} : { gaps }), + ...(units === undefined ? {} : { units }), }; } diff --git a/src/features/preview/spacingBands.ts b/src/features/preview/spacingBands.ts index 986045d0..0ec3936e 100644 --- a/src/features/preview/spacingBands.ts +++ b/src/features/preview/spacingBands.ts @@ -10,6 +10,8 @@ export interface Spacing { readonly padding?: SpacingSides; readonly margin?: SpacingSides; readonly gaps?: readonly (Box & { readonly axis: 'row' | 'column' })[]; + /** What one `em`, `rem`, `vw` and `vh` is worth in px for this element. */ + readonly units?: Readonly>; } interface Band extends Box { readonly side: string; @@ -110,4 +112,63 @@ export function spacingBands( ); } +const LENGTH = /^\s*(-?(?:\d+\.?\d*|\.\d+))\s*(px|rem|em|vw|vh)?\s*(?:!important)?\s*$/i; + +/** A value the panel is dragging, in pixels, or undefined when only the laid-out + * page can say: a percentage, a variable, a calc(). Units come from the page + * with its measurements: em is the element's own font size, rem the root's. */ +export function lengthToPx( + value: string, + units: Readonly> | undefined, +): number | undefined { + const match = LENGTH.exec(value); + if (!match) { + return undefined; + } + const amount = parseFloat(match[1] ?? ''); + const unit = (match[2] ?? '').toLowerCase(); + if (unit === '') { + // A bare number is a length only when it is 0. + return amount === 0 ? 0 : undefined; + } + if (unit === 'px') { + return amount; + } + const perUnit = units?.[unit]; + if (perUnit === undefined) { + return undefined; + } + return perUnit > 0 ? amount * perUnit : undefined; +} + +/** + * `spacing` with the sides a drag is writing replaced by the dragged values, so + * the band on the canvas is the number in the panel, on the same frame, rather + * than the page as it was last measured. A value that cannot be turned into + * pixels here keeps its measurement. + */ +export function withLiveSpacing( + spacing: Spacing | undefined, + kind: string, + live: Readonly> | undefined, +): Spacing | undefined { + if (!spacing || !live) { + return spacing; + } + if (kind !== 'padding' && kind !== 'margin') { + return spacing; + } + const sizes: { [Key in Side]?: number } = { ...spacing[kind] }; + let changed = false; + for (const side of SIDES) { + const value = live[side]; + const px = value === undefined ? undefined : lengthToPx(value, spacing.units); + if (px !== undefined) { + sizes[side] = px; + changed = true; + } + } + return changed ? { ...spacing, [kind]: sizes } : spacing; +} + export default spacingBands; diff --git a/src/features/style/SpacingFill.tsx b/src/features/style/SpacingFill.tsx index a527f078..f21833a9 100644 --- a/src/features/style/SpacingFill.tsx +++ b/src/features/style/SpacingFill.tsx @@ -185,6 +185,8 @@ export type FillProps = { export type SideDragOptions = { propFor: (side: Side) => string; inward: boolean; + /** The box the drag is drawn in on the canvas — see useSideHover. */ + kind: 'padding' | 'margin'; read: Read; busy: boolean; setProp: SetProp; @@ -194,9 +196,73 @@ export type SideDragOptions = { threshold?: number; }; +// While a drag is on, the canvas hears its value from the drag (reportDrag) and +// not from the hover, which would report what the file still says: the band's +// label stayed on the old value for the whole drag. One drag at a time, so one +// flag, written only by reportDrag and by a drag that unmounts mid-flight. +let dragReporting = false; + +// Tell the canvas what a drag holds for `sides`: the label to write on each +// band and, while the drag is live, the value to size it from. Only a real +// padding or margin side can be sized ahead of the page; an inset (`top` on a +// positioned element) is labelled but measured. On release the label stays and +// the size is measured again, which by then is the same number. +function reportDrag( + box: { readonly kind: 'padding' | 'margin'; readonly propFor: (side: Side) => string }, + sides: readonly Side[], + held: { readonly value: string; readonly live: boolean }, +): void { + dragReporting = held.live; + const labels: Record = {}; + const ahead: Record = {}; + for (const side of sides) { + labels[side] = held.value; + if (held.live && /^(padding|margin)-/.test(box.propFor(side))) { + ahead[side] = held.value; + } + } + getHost().onSpacingHover?.({ + kind: box.kind, + sides: [...sides], + labels, + ...(Object.keys(ahead).length > 0 ? { live: ahead } : {}), + }); +} + +// What a drag tells the canvas, from its first frame to its release. +function useDragReport( + dragRef: React.MutableRefObject, + box: { readonly kind: 'padding' | 'margin'; readonly propFor: (side: Side) => string }, +) { + // A drag that goes away mid-flight must not leave the hover silenced. + useEffect( + () => () => { + if (dragRef.current) { + dragReporting = false; + } + }, + [dragRef], + ); + return { + // The canvas, on the same frame as the labels: the band it draws for these + // sides is labelled and sized from this value, so it passes through every + // number the panel shows rather than the ones the page had time to lay out. + live: (drag: SideDrag, display: string): void => { + const value = display.replace(/ !important$/, ''); + reportDrag(box, affectedSides(drag.side, drag), { value, live: true }); + }, + // The last value stays on the band; its size goes back to being measured. + // The hover takes over from here. + release: (drag: SideDrag, value: string): void => { + reportDrag(box, affectedSides(drag.side, drag), { value, live: false }); + }, + }; +} + export function useSideDrag({ propFor, inward, + kind, read, busy, setProp, @@ -207,7 +273,11 @@ export function useSideDrag({ }: SideDragOptions) { const dragRef = useRef(undefined); const dragged = useRef(false); - const frame = useLiveFrame(dragRef, liveSetProp, onLive); + const report = useDragReport(dragRef, { kind, propFor }); + const frame = useLiveFrame(dragRef, liveSetProp, (props, display, drag) => { + onLive(props, display); + report.live(drag, display); + }); const apply = useDragApply({ dragRef, @@ -261,6 +331,7 @@ export function useSideDrag({ return; } onLiveEnd(); + report.release(drag, value); drag.props.forEach((prop) => setProp(prop, value, drag.important)); }; @@ -420,7 +491,7 @@ export function followPointer( export function useLiveFrame( dragRef: React.MutableRefObject, liveSetProp: LiveSetProp, - onLive: (props: string[], display: string) => void, + onLive: (props: string[], display: string, drag: SideDrag) => void, ) { const raf = useRef(undefined); const pending = useRef(undefined); @@ -437,7 +508,7 @@ export function useLiveFrame( const drag = dragRef.current; const value = pending.current; if (drag && value !== undefined) { - onLive(drag.props, drag.important ? `${value} !important` : value); + onLive(drag.props, drag.important ? `${value} !important` : value, drag); // Linked sides are one visual edit. Sending them together lets the style // writer serialize the shared stylesheet once instead of once per side. liveSetProp(drag.props, value, drag.important); @@ -507,6 +578,9 @@ export function useSideHover({ const overRef = useRef(undefined); const report = () => { + if (dragReporting) { + return; // The drag is saying what the band shows. + } const hovered = overRef.current; if (!hovered) { getHost().onSpacingHover?.(undefined); @@ -626,12 +700,13 @@ export function useHoverModifiers( export function SpacingFill({ frame, inward = false, ...dragOptions }: FillProps) { const geometry = FRAMES[frame]; const maskId = 'sp-' + useId().replace(/:/g, ''); - const { onPointerDown, onPointerMove, onPointerUp } = useSideDrag({ ...dragOptions, inward }); - const hover = useSideHover({ - propFor: dragOptions.propFor, - kind: inward ? 'padding' : 'margin', - read: dragOptions.read, + const kind = inward ? 'padding' : 'margin'; + const { onPointerDown, onPointerMove, onPointerUp } = useSideDrag({ + ...dragOptions, + inward, + kind, }); + const hover = useSideHover({ propFor: dragOptions.propFor, kind, read: dragOptions.read }); return ( ) { const propForSide = (other: Side) => siblingProps(prop, side, [other])[0] ?? prop; + // `padding-top` → padding, `margin-top` → margin, a bare inset (`top`) → the + // box it is drawn in, which is the margin frame. + const kind = prop.startsWith('padding') ? 'padding' : 'margin'; const drag = useSideDrag({ propFor: propForSide, inward: prop.startsWith('padding'), + kind, read, busy, setProp, @@ -165,13 +169,7 @@ export function useLabelGesture({ onLiveEnd, threshold: 3, }); - // `padding-top` → padding, `margin-top` → margin, a bare inset (`top`) → the - // box it is drawn in, which is the margin frame. - const hover = useSideHover({ - propFor: propForSide, - kind: prop.startsWith('padding') ? 'padding' : 'margin', - read, - }); + const hover = useSideHover({ propFor: propForSide, kind, read }); return { ...drag, hover }; } diff --git a/src/features/style/model/host.ts b/src/features/style/model/host.ts index 134ea18d..ac2a54f0 100644 --- a/src/features/style/model/host.ts +++ b/src/features/style/model/host.ts @@ -30,6 +30,11 @@ export type SpacingHover = { /** Edges for padding and margin; axes for gap. */ sides: Array<'top' | 'right' | 'bottom' | 'left' | 'row' | 'column'>; labels: Record; + /** The values a drag is writing right now, per side, as authored ("4.8rem"): + * the canvas sizes the band from them on the frame the number changes, + * instead of waiting for the page to lay out and be measured. Absent outside + * a drag; the band is then the measured one. */ + live?: Record; }; /** What became of a class added to the page, for the rule that depends on it. */ diff --git a/test/renderer/preview/previewMessages.test.js b/test/renderer/preview/previewMessages.test.js index 54c7473e..c3bea988 100644 --- a/test/renderer/preview/previewMessages.test.js +++ b/test/renderer/preview/previewMessages.test.js @@ -17,6 +17,7 @@ const spacing = { padding: { top: 2, right: 3, bottom: 4, left: 5 }, margin: { top: 1 }, gaps: [{ ...box, axis: 'row' }], + units: { em: 20, rem: 16, vw: 12.8, vh: 7.2 }, }; test('preview message parser preserves every supported message variant', () => { @@ -124,6 +125,9 @@ test('preview message parser ignores unknown and malformed project messages', () classes: {}, spacing: { 0: [{ gaps: [{ ...box, axis: 'diagonal' }] }] }, }, + { type: 'avb:rects', rects: {}, classes: {}, spacing: { 0: [{ units: { rem: -16 } }] } }, + { type: 'avb:rects', rects: {}, classes: {}, spacing: { 0: [{ units: { rem: '16' } }] } }, + { type: 'avb:rects', rects: {}, classes: {}, spacing: { 0: [{ units: [16] }] } }, { type: 'avb:click-node', path: '0', occurrence: -1, outside: false }, { type: 'avb:modifiers', shiftKey: 'yes', altKey: false }, { type: 'avb:rendered-nodes', paths: Array(100_001).fill('0') }, diff --git a/test/renderer/preview/spacingLive.test.js b/test/renderer/preview/spacingLive.test.js new file mode 100644 index 00000000..80a9fd25 --- /dev/null +++ b/test/renderer/preview/spacingLive.test.js @@ -0,0 +1,88 @@ +// Goal: the band on the canvas is the number in the panel, on the same frame. +// Dragging a side of the spacing box runs the panel's number through every +// value on the way, but the band drawn over the page was sized from the page as +// last measured: the value had to be written, laid out and reported back, a few +// frames behind, and its label came from the hover, so from the file, and kept +// the old value for the whole drag. The drag now hands the canvas its value +// (SpacingHover.live), and the canvas turns it into pixels with the units the +// page reports alongside its measurements. +// Method: the canvas half, on its pure functions. lengthToPx is given every +// kind of value a drag can hold, and the ones only the laid-out page can size +// (a percentage, a variable, a calc(), a unit the page did not price), which +// must come back undefined rather than guessed. withLiveSpacing is checked for +// what it replaces and for everything it must leave alone, by identity. Then +// spacingBands draws the result, as the overlay does. The panel half, a real +// drag reporting to the canvas, is in test/renderer/style/spacingDragReport. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { lengthToPx, withLiveSpacing, spacingBands } = require('../../helpers/rendererModule')( + 'src/features/preview/spacingBands.ts', +); + +const units = { em: 20, rem: 16, vw: 12, vh: 8 }; +const measured = { + padding: { top: 16, right: 0, bottom: 16, left: 0 }, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + gaps: [], + units, +}; + +test('a length the page priced is turned into pixels', () => { + const cases = [ + ['12px', 12], + ['2rem', 32], + ['1.5em', 30], + ['10vw', 120], + ['5vh', 40], + ['.5rem', 8], + ['-1rem', -16], + ['3rem !important', 48], + ['2REM', 32], + ['0', 0], + ]; + for (const [value, px] of cases) { + assert.equal(lengthToPx(value, units), px, value); + } +}); + +test('a value only the laid-out page can size is not guessed', () => { + for (const value of ['4', '10%', 'var(--space-4)', 'calc(1rem + 2px)', 'auto', '', '1rem 2rem']) { + assert.equal(lengthToPx(value, units), undefined, value); + } + assert.equal(lengthToPx('2rem', {}), undefined, 'a unit the page did not price'); + assert.equal(lengthToPx('2rem', undefined), undefined, 'a page that priced nothing'); + assert.equal(lengthToPx('2rem', { rem: 0 }), undefined, 'a unit worth nothing'); + assert.equal(lengthToPx('12px', undefined), 12, 'px needs no price'); +}); + +test('a dragged side takes the dragged value; the rest keep their measurement', () => { + const live = withLiveSpacing(measured, 'padding', { top: '3rem' }); + assert.deepEqual(live.padding, { top: 48, right: 0, bottom: 16, left: 0 }); + assert.equal(measured.padding.top, 16, 'the measurement itself is left alone'); + assert.equal(live.margin, measured.margin, 'the other box is untouched'); + assert.equal(live.units, units); + const margin = withLiveSpacing(measured, 'margin', { left: '-1rem', right: '10%' }); + assert.deepEqual(margin.margin, { top: 0, right: 0, bottom: 0, left: -16 }); +}); + +test('nothing sizable dragged, nothing changed', () => { + assert.equal(withLiveSpacing(measured, 'padding', { top: 'var(--space-8)' }), measured); + assert.equal(withLiveSpacing(measured, 'margin', undefined), measured); + assert.equal(withLiveSpacing(measured, 'padding', {}), measured); + assert.equal(withLiveSpacing(measured, 'gap', { row: '1rem' }), measured, 'gaps are measured'); + assert.equal(withLiveSpacing(undefined, 'padding', { top: '1rem' }), undefined); + const unpriced = { padding: { top: 16 } }; + assert.equal(withLiveSpacing(unpriced, 'padding', { top: '2rem' }), unpriced); +}); + +test('the band passes through every value the panel shows', () => { + const box = { x: 100, y: 200, w: 400, h: 300 }; + const heights = ['1rem', '2rem', '3rem', '16rem'].map((value) => { + const sized = withLiveSpacing(measured, 'padding', { top: value }); + return spacingBands(box, sized, 'padding', ['top'])[0]?.h; + }); + assert.deepEqual(heights, [16, 32, 48, 256]); + const outside = withLiveSpacing(measured, 'margin', { top: '2rem' }); + const band = spacingBands(box, outside, 'margin', ['top'])[0]; + assert.deepEqual([band?.y, band?.h], [200 - 32, 32], 'a margin band grows outward'); +}); diff --git a/test/renderer/style/spacingDragReport.test.js b/test/renderer/style/spacingDragReport.test.js new file mode 100644 index 00000000..73f36be3 --- /dev/null +++ b/test/renderer/style/spacingDragReport.test.js @@ -0,0 +1,188 @@ +// Goal: a drag in the spacing box tells the canvas its value while it moves. +// The band over the page is labelled by whoever reported last, and only the +// hover reported: it reads the file, so the label kept the old value for the +// whole drag ("2rem" under a number already at 3). The drag now reports its own +// value on each frame, with the sides Shift and Alt bring in, and the hover +// stays silent meanwhile, so a modifier pressed mid-drag cannot put the file's +// value back on the band. +// Method: the real EmbedEditor in jsdom over a stylesheet bridge, with the +// host's onSpacingHover recorded, as the app records it to draw the bands. The +// pointer enters the top padding label (the hover reports the file's value), +// presses, moves, moves again with Shift, and releases. Each report is checked +// for its sides, its labels and its `live` values: present on every frame of +// the drag, equal to the label, absent again on release, where the label stays. +// The negative space is the file's value: no report between press and release +// may carry it. The canvas half, sizing the band from `live`, is in +// test/renderer/preview/spacingLive. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const esbuild = require('esbuild'); +const { JSDOM } = require('jsdom'); +const { ROOT, repoPath } = require('../../helpers/sources.js'); + +const SIDES = ['top', 'right', 'bottom', 'left']; +const POLL_MS = 25; +const POLLS_MAX = 200; +const SHEET = { + rel: 'src/styles/main.css', + name: 'main.css', + path: '/p/src/styles/main.css', + size: 10, +}; + +// The jsdom window the panel runs in, with the globals React and the panel read. +function openWindow() { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + const { window } = dom; + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + window.Element.prototype.setPointerCapture = () => {}; + window.Element.prototype.releasePointerCapture = () => {}; + global.window = window; + global.document = window.document; + global.navigator = window.navigator; + global.IS_REACT_ACT_ENVIRONMENT = false; + for (const key of ['ResizeObserver', 'MutationObserver', 'Element', 'HTMLElement', 'Node']) { + global[key] = window[key]; + } + for (const key of ['getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame']) { + global[key] = window[key].bind(window); + } + return window; +} + +// The panel and its host setter, bundled as the app loads them. +function loadPanel() { + const directory = repoPath('node_modules/.stacki-test'); + fs.mkdirSync(directory, { recursive: true }); + const bundle = path.join(directory, 'spacing-drag-report.bundle.js'); + esbuild.buildSync({ + stdin: { + contents: [ + "export { default as EmbedEditor } from './src/features/style/EmbedEditor';", + "export { setHost } from './src/features/style/model/host';", + ].join('\n'), + resolveDir: ROOT, + loader: 'tsx', + }, + outfile: bundle, + bundle: true, + format: 'cjs', + platform: 'node', + jsx: 'automatic', + external: ['react', 'react-dom', 'react/jsx-runtime', 'react-dom/client'], + loader: { '.css': 'empty' }, + logLevel: 'silent', + }); + return require(bundle); +} + +test('a drag reports its value to the canvas; the hover stays silent meanwhile', async () => { + const window = openWindow(); + const { EmbedEditor, setHost } = loadPanel(); + const React = require('react'); + const { createRoot } = require('react-dom/client'); + + // One longhand per side: the box reads those on every version of the panel. + let disk = `.card { ${SIDES.map((side) => `padding-${side}: 2rem;`).join(' ')} }`; + window.avb = { + listStyleFiles: async () => ({ files: [SHEET] }), + listAstroStyleFiles: async () => ({ files: [] }), + listAssets: async () => ({ entries: [] }), + readStyleFile: async () => ({ css: disk }), + writeStyleFile: async ({ css }) => { + disk = css; + return { ok: true }; + }, + }; + const reports = []; + const card = { type: 'string', value: 'card' }; + setHost({ + projectPath: '/p', + nodes: [{ id: 'n1', kind: 'element', name: 'div', props: { class: card } }], + selectedId: 'n1', + files: [SHEET], + astroFiles: [], + renderedClasses: [], + pathOf: () => '0.1', + onSpacingHover: (hover) => reports.push(hover), + }); + const panel = window.document.getElementById('root'); + const root = createRoot(panel); + root.render(React.createElement(EmbedEditor)); + + // Unmounted on failure too: a mounted panel keeps the process alive. + try { + const wait = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + const until = async (what, read) => { + for (let tries = 0; tries < POLLS_MAX; tries += 1) { + const found = read(); + if (found) { + return found; + } + await wait(POLL_MS); + } + return assert.fail(`timed out waiting for ${what}`); + }; + const label = (side) => panel.querySelector(`button[data-prop="padding-${side}"]`); + const send = async (type, init) => { + const event = new window.MouseEvent(type, { bubbles: true, clientX: 100, ...init }); + label('top').dispatchEvent(event); + await wait(POLL_MS * 2); + }; + const chip = await until('the class chip', () => + panel.querySelector('.embed-editor_selector-chip'), + ); + chip.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await until('the value on the top label', () => label('top')?.textContent === '2rem'); + await until('the label to be ready', () => !label('top').disabled); + + await send('pointerover', { clientY: 100 }); + assert.deepEqual(reports.at(-1), { kind: 'padding', sides: ['top'], labels: { top: '2rem' } }); + + reports.length = 0; + await send('pointerdown', { clientY: 100 }); + await send('pointermove', { clientY: 80 }); + const alone = await until('the drag to report', () => reports.at(-1)); + assert.deepEqual(alone.sides, ['top']); + assert.notEqual(alone.labels.top, '2rem', 'the label is the dragged value'); + assert.equal(alone.labels.top, label('top').textContent, 'the number the panel shows'); + assert.deepEqual(alone.live, alone.labels, 'and the band is sized from it'); + + // Shift mid-drag: the hover sees the modifier change too, and must not answer. + await send('pointermove', { clientY: 60, shiftKey: true }); + const all = await until('the four sides to report', () => + reports.at(-1).sides.length === 4 ? reports.at(-1) : undefined, + ); + assert.deepEqual([...all.sides].sort(), ['bottom', 'left', 'right', 'top']); + assert.equal(new Set(Object.values(all.labels)).size, 1, 'one value on the four bands'); + assert.deepEqual(all.live, all.labels); + assert.notEqual(all.labels.top, alone.labels.top, 'the value moved on'); + + await send('pointerup', { clientY: 60, shiftKey: true }); + const released = reports.at(-1); + assert.deepEqual(released.labels, all.labels, 'the last value stays on the band'); + assert.equal('live' in released, false, 'and its size is measured again'); + for (const report of reports) { + assert.ok(report, 'the band is never cleared during the drag'); + assert.equal(Object.values(report.labels).includes('2rem'), false, 'never the file’s value'); + } + await until('the write', () => disk.includes(`: ${all.labels.top}`) && !disk.includes('2rem')); + + // The drag is over: the hover answers again, with what the file now holds. + reports.length = 0; + await until('the panel to settle', () => !label('top').disabled); + await send('pointerout', { clientY: 60 }); + await send('pointerover', { clientY: 60 }); + const hovered = await until('the hover to report', () => reports.at(-1)); + assert.deepEqual(hovered, { kind: 'padding', sides: ['top'], labels: { top: all.labels.top } }); + } finally { + root.unmount(); + window.close(); + } +});