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
4 changes: 4 additions & 0 deletions electron/preload/frameBasics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export type GapRow = { top: number; bottom: number; items: DOMRect[] };
// come from the project's markup, and what lies below the bound is skipped.
export const PRELOAD_LIMITS = { domDepthMax: 512, cssRuleDepthMax: 64 } as const;

// Marks the elements the canvas adds to the page to measure it: its own
// writes, which never start a page walk (mutationKind in frameReports.ts).
export const PROBE_ATTR = 'data-avb-probe';

export const isElement = (node: Node): node is Element => node.nodeType === 1;
export const isComment = (node: Node): node is Comment => node.nodeType === 8;
export const isText = (node: Node): node is Text => node.nodeType === 3;
Expand Down
5 changes: 4 additions & 1 deletion electron/preload/frameDesign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ function forwardShortcut(event: KeyboardEvent): void {
// usually made, so it has to reach the app from in here too.
const isClassJump = mod && !event.altKey && !event.shiftKey && event.key === 'Enter';
const isProperties = !mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k';
if (isDelete || isDuplicate || isClassJump || isProperties) {
// ⇧G shows or hides the column guide drawn over this frame. It is replayed
// as the key "G", which says Shift on its own (src/features/preview/guideSettings.ts).
const isGuide = !mod && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'g';
if (isDelete || isDuplicate || isClassJump || isProperties || isGuide) {
event.preventDefault();
try {
window.parent.postMessage(
Expand Down
243 changes: 243 additions & 0 deletions electron/preload/frameGuide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// What the column guide is drawn from: the page's own layout values, measured
// where they apply. The guide is drawn by the app over the frame
// (src/features/preview/GuideOverlay.tsx); the frame only says how wide the
// page is and what its margin, gutter, maximum width and column count come to
// at that width.
//
// The values are read from the page, never written to it. A project that
// declares them (Lumos does: `--site-margin`, `--site-gutter`,
// `--max-width-main`) gets a guide that follows its fluid `clamp()` values at
// every preview width; a project that declares none gets `undefined` for each.
// A variable typed into the guide's menu (`--container-padding`) is sent here
// by name and measured the same way, so a project with its own names follows
// its own fluid values too; each name is also read as a whole number, for a
// column count of the project's own (`--grid-columns`). Which value wins is the app's to decide
// (src/features/preview/guideModel.ts).

import { PROBE_ATTR } from './frameBasics';

// The custom properties a guide is built from, as Lumos names them. The app
// keeps the same names (GUIDE_PROJECT_VARIABLES): the preload imports nothing
// of the renderer's.
const GUIDE_VARIABLES = {
margin: '--site-margin',
gutter: '--site-gutter',
widthMax: '--max-width-main',
} as const;
// A site-wide column count on :root, tried in this order. The app's parser
// accepts exactly these names (guideModel.ts).
const SITE_COLUMN_COUNT_VARIABLES = ['--site-column-count', '--site-columns'] as const;
// Lumos keeps its column count on its auto grid rather than on :root, so a
// page with no site-wide count is asked through an element of that class.
const GRID_CLASS = 'grid-autofit';
const GRID_COLUMN_COUNT_VARIABLE = '--_column-count';
const COLUMN_COUNT_PATTERN = /^\d{1,3}$/;
// The names the app may ask about: one per menu value (margin, gutter, maximum
// width and column count), each a custom property name within the app's own
// bound (guideModel.ts).
const MENU_VARIABLES_MAX = 4;
const MENU_VARIABLE_CHARS_MAX = 64;
const MENU_VARIABLE_PATTERN = /^--[A-Za-z0-9_-]+$/;

/** How the host sends to the editor: the desktop canvas and Studio each post their own way. */
export type GuidePost = (message: unknown) => void;

/** The page side of the column guide, as a host wires it. */
export interface GuideFrame {
/** Handles a message from the editor, once the host has checked its sender. */
readonly receive: (data: unknown) => void;
}

// What was last posted, so a measurement that changed nothing stays quiet.
let lastSent = '';
let measureQueued = false;
// The variables typed into the guide's menu, as the editor last sent them.
let menuVariables: readonly string[] = [];
let post: GuidePost = () => undefined;

/** Starts reporting the guide's measurements, now and whenever they may change. */
export function startGuide(send: GuidePost): GuideFrame {
post = send;
queueGuide();
window.addEventListener('load', queueGuide);
window.addEventListener('resize', queueGuide);
// A page patched in place after an edit (electron/previewClient/morphClient.ts).
document.addEventListener('avb:morphed', queueGuide);
watchStyles();
return { receive: receiveGuideMessage };
}

// The editor sends the menu's variables when a frame loads and whenever they
// change. Each message asks for a full report, even one that repeats the last:
// the editor listening now may not have heard the earlier ones.
function receiveGuideMessage(data: unknown): void {
if (messageType(data) === 'avb:guide-variables') {
menuVariables = parseMenuVariables(data);
lastSent = '';
queueGuide();
}
}

// The dev server delivers CSS by swapping a <style> in <head>, so an edited
// margin arrives there. A changed root font size or layout arrives as a resize.
function watchStyles(): void {
try {
new MutationObserver(queueGuide).observe(document.head, {
childList: true,
subtree: true,
characterData: true,
});
new ResizeObserver(queueGuide).observe(document.documentElement);
} catch {
/* No observers: load and resize still report. */
}
}

// One measurement per frame, however many changes asked for it.
function queueGuide(): void {
if (measureQueued) {
return;
}
measureQueued = true;
requestAnimationFrame(() => {
measureQueued = false;
sendGuide();
});
}

function sendGuide(): void {
const body = document.body;
if (!body) {
return;
}
const message = measureGuide(body);
const key = JSON.stringify(message);
if (key === lastSent) {
return;
}
lastSent = key;
try {
post(message);
} catch {
/* The editor went away. */
}
}

// A throwaway element in the body takes each value as its width, and the
// engine resolves it: `clamp()`, `rem` and `100vw` come back as pixels at the
// frame's current width. Each probe is removed before the frame paints.
function measureGuide(body: HTMLElement) {
const root = document.documentElement;
const rootStyle = getComputedStyle(root);
return {
type: 'avb:guide',
viewportWidth: root.clientWidth,
rootFontSize: Number.parseFloat(rootStyle.fontSize),
margin: lengthOf(body, rootStyle, GUIDE_VARIABLES.margin),
gutter: lengthOf(body, rootStyle, GUIDE_VARIABLES.gutter),
widthMax: lengthOf(body, rootStyle, GUIDE_VARIABLES.widthMax),
variables: Object.fromEntries(
menuVariables.map((name) => [name, lengthOf(body, rootStyle, name)]),
),
// The same names read as a column count: a whole number on :root.
counts: Object.fromEntries(
menuVariables.map((name) => [name, countOf(rootStyle.getPropertyValue(name))]),
),
...columnCountOf(body, rootStyle),
};
}

// A declared length, in pixels, or undefined for one the page does not declare.
function lengthOf(
body: HTMLElement,
rootStyle: CSSStyleDeclaration,
variable: string,
): number | undefined {
const declared = rootStyle.getPropertyValue(variable).trim();
if (declared === '' || declared === 'none') {
return undefined;
}
const probe = appendProbe(body, `width:var(${variable})`);
try {
const width = probe.getBoundingClientRect().width;
return Number.isFinite(width) ? width : undefined;
} finally {
probe.remove();
}
}

// The site-wide count when the page declares one, else Lumos's grid count.
function columnCountOf(body: HTMLElement, rootStyle: CSSStyleDeclaration) {
for (const variable of SITE_COLUMN_COUNT_VARIABLES) {
const site = countOf(rootStyle.getPropertyValue(variable));
if (site !== undefined) {
return { columnCount: site, columnVariable: variable };
}
}
const probe = appendProbe(body, '');
probe.className = GRID_CLASS;
try {
const grid = countOf(getComputedStyle(probe).getPropertyValue(GRID_COLUMN_COUNT_VARIABLE));
if (grid !== undefined) {
return { columnCount: grid, columnVariable: GRID_COLUMN_COUNT_VARIABLE };
}
return { columnCount: undefined, columnVariable: undefined };
} finally {
probe.remove();
}
}

// An invisible, zero-height element the canvas owns: its own writes never
// start a page walk (mutationKind in frameReports.ts).
function appendProbe(body: HTMLElement, style: string): HTMLElement {
const probe = document.createElement('div');
probe.setAttribute(PROBE_ATTR, '');
probe.setAttribute(
'style',
'position:absolute;left:0;top:0;height:0;margin:0;padding:0;border:0;' +
'box-sizing:content-box;visibility:hidden;pointer-events:none;' +
style,
);
body.appendChild(probe);
return probe;
}

function countOf(declared: string): number | undefined {
const value = declared.trim();
return COLUMN_COUNT_PATTERN.test(value) ? Number.parseInt(value, 10) : undefined;
}

function messageType(input: unknown): unknown {
if (typeof input !== 'object' || input === null) {
return undefined;
}
return 'type' in input ? input.type : undefined;
}

// The names the app sent, checked here too: each is written into a probe's
// style attribute as `var(<name>)`, so only plain custom property names are
// accepted. A message with an invalid name, or too many, measures nothing.
function parseMenuVariables(input: unknown): readonly string[] {
if (typeof input !== 'object' || input === null) {
return [];
}
const names: unknown = 'names' in input ? input.names : undefined;
if (!Array.isArray(names)) {
return [];
}
if (names.length > MENU_VARIABLES_MAX) {
return [];
}
const valid = names.filter(isMenuVariable);
return valid.length === names.length ? valid : [];
}

function isMenuVariable(name: unknown): name is string {
if (typeof name !== 'string') {
return false;
}
if (name.length > MENU_VARIABLE_CHARS_MAX) {
return false;
}
return MENU_VARIABLE_PATTERN.test(name);
}
9 changes: 7 additions & 2 deletions electron/preload/frameQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
// it tracks, scrolling a node into view and the viewport height to freeze; and
// starting the frame once the document is ready.

import { isElement, isText } from './frameBasics';
import { PROBE_ATTR, isElement, isText } from './frameBasics';
import { queryAuthoredStyles } from './frameStyleSources';
import { list, pathText, text } from '../../shared/core/boundary';
import { LIMITS } from '../../shared/core/limits';
import { freezeViewportHeight, report } from './frameViewport';
import { startGuide, type GuideFrame } from './frameGuide';
import {
PATH_ATTR,
STAMP_ATTRIBUTE,
Expand All @@ -24,7 +25,6 @@ import {
} from './frameRegions';
import { ownClasses, resetThinCache } from './frameMeasure';
import {
PROBE_ATTR,
forgetReportedKeys,
paintOpened,
scrollPathIntoView,
Expand Down Expand Up @@ -136,6 +136,7 @@ export function listenForQueries(): void {
reportRender();
return;
}
guideFrame?.receive(data);
if (data['type'] === 'avb:track' && Array.isArray(data['paths'])) {
track(data, data['paths']);
}
Expand Down Expand Up @@ -380,9 +381,13 @@ const track = (data: Record<string, unknown>, paths: unknown[]) => {
}
};

// The column guide's page side, once the frame has started.
let guideFrame: GuideFrame | undefined;

const start = () => {
report();
startOutlines();
guideFrame = startGuide((message) => window.parent.postMessage(message, '*'));
// Tell the app which route this frame is on (used by interactive
// preview mode to follow link navigation).
try {
Expand Down
3 changes: 1 addition & 2 deletions electron/preload/frameReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { authoredStyleMutation } from './frameAuthoredStyles';
// writes never trigger a pass.

import type { Rect, Spacing } from './frameBasics';
import { isComment, isElement, isText } from './frameBasics';
import { PROBE_ATTR, isComment, isElement, isText } from './frameBasics';
import {
PATH_ATTR,
elementsWithPath,
Expand Down Expand Up @@ -470,7 +470,6 @@ export const remeasureStyles = () => {
// colour the style panel asked about. Its own writes are now told apart by
// what they touched, never by timing.
type MutationKind = 'none' | 'css' | 'page';
export const PROBE_ATTR = 'data-avb-probe';
const ownElement = (node: Node): boolean => {
if (!isElement(node)) {
return false;
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,17 @@ const SOURCE_LAYERS = {
],
// The history panel draws commits with git's file and branch widgets.
history: ['features/git/BranchActions', 'features/git/FileBrowser', 'features/git/FileStatus'],
// The column guide's menu is built from the style panel's controls.
preview: [
'features/style/LayerPopover',
'features/style/SizeKit',
'features/style/VariableConnect',
'features/style/components/ColorSwatch',
'features/style/components/DragSlider',
'features/style/components/FieldLabel',
'features/style/components/Select',
'features/style/components/useScrub',
],
// A variable's value is edited with the style panel's value editors.
variables: [
'features/style/CustomValueEditor',
Expand Down
1 change: 1 addition & 0 deletions src/app/shell/AppPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function CanvasPane({ app }: { readonly app: ShellView }) {
focusWhole={app.focusWhole}
device={app.device}
onDevice={app.setDevice}
projectPath={app.project.path}
judgeEvent={app.judgeEvent}
onStaleEvent={app.onStaleEvent}
onPreviewReload={app.onPreviewReload}
Expand Down
Loading