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(); + } +});