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
2 changes: 2 additions & 0 deletions electron/preload/frameBasics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type Spacing = {
padding: Record<string, number>;
margin: Record<string, number>;
gaps: GapBand[];
/** What one em, rem, vw and vh is worth in px for the element measured. */
units: Record<string, number>;
};
export type GapRow = { top: number; bottom: number; items: DOMRect[] };

Expand Down
13 changes: 12 additions & 1 deletion electron/preload/frameMeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 15 additions & 14 deletions src/features/preview/PreviewOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,6 +32,8 @@ export interface SpacingHover {
readonly kind: 'padding' | 'margin' | 'gap';
readonly sides?: readonly string[];
readonly labels?: Readonly<Record<string, string>>;
/** The values a drag is writing, per side: they size the band ahead of the page. */
readonly live?: Readonly<Record<string, string>>;
}

// The measurements as the canvas frame reports them (src/features/preview/previewMessages.ts): a
Expand Down Expand Up @@ -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) => (
<div
key={`sp-${band.side}-${index}`}
className={`spacing-band is-${spacingHover.kind}`}
style={boxStyle(band, props.scale)}
>
<span className="spacing-band-label">
{spacingHover.labels?.[band.side] ?? spacingLabel(band)}
</span>
</div>
),
);
const sized = withLiveSpacing(measurement, spacingHover.kind, spacingHover.live);
return spacingBands(box, sized, spacingHover.kind, spacingHover.sides).map((band, index) => (
<div
key={`sp-${band.side}-${index}`}
className={`spacing-band is-${spacingHover.kind}`}
style={boxStyle(band, props.scale)}
>
<span className="spacing-band-label">
{spacingHover.labels?.[band.side] ?? spacingLabel(band)}
</span>
</div>
));
}

function NodeOutlines(props: PreviewOverlaysProps) {
Expand Down
2 changes: 2 additions & 0 deletions src/features/preview/previewMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};
}

Expand Down
61 changes: 61 additions & 0 deletions src/features/preview/spacingBands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>>;
}
interface Band extends Box {
readonly side: string;
Expand Down Expand Up @@ -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<Record<string, number>> | 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<Record<string, string>> | 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;
91 changes: 83 additions & 8 deletions src/features/style/SpacingFill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string> = {};
const ahead: Record<string, string> = {};
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<SideDrag | undefined>,
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,
Expand All @@ -207,7 +273,11 @@ export function useSideDrag({
}: SideDragOptions) {
const dragRef = useRef<SideDrag | undefined>(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,
Expand Down Expand Up @@ -261,6 +331,7 @@ export function useSideDrag({
return;
}
onLiveEnd();
report.release(drag, value);
drag.props.forEach((prop) => setProp(prop, value, drag.important));
};

Expand Down Expand Up @@ -420,7 +491,7 @@ export function followPointer(
export function useLiveFrame(
dragRef: React.MutableRefObject<SideDrag | undefined>,
liveSetProp: LiveSetProp,
onLive: (props: string[], display: string) => void,
onLive: (props: string[], display: string, drag: SideDrag) => void,
) {
const raf = useRef<number | undefined>(undefined);
const pending = useRef<string | undefined>(undefined);
Expand All @@ -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);
Expand Down Expand Up @@ -507,6 +578,9 @@ export function useSideHover({
const overRef = useRef<HoveredSide | undefined>(undefined);

const report = () => {
if (dragReporting) {
return; // The drag is saying what the band shows.
}
const hovered = overRef.current;
if (!hovered) {
getHost().onSpacingHover?.(undefined);
Expand Down Expand Up @@ -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 (
<svg
Expand Down
12 changes: 5 additions & 7 deletions src/features/style/SpacingLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ export function useLabelGesture({
'prop' | 'side' | 'read' | 'busy' | 'setProp' | 'liveSetProp' | 'onLive' | 'onLiveEnd'
>) {
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,
Expand All @@ -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 };
}

Expand Down
5 changes: 5 additions & 0 deletions src/features/style/model/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** 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<string, string>;
};

/** What became of a class added to the page, for the rule that depends on it. */
Expand Down
4 changes: 4 additions & 0 deletions test/renderer/preview/previewMessages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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') },
Expand Down
Loading
Loading