diff --git a/electron/preload/frameBasics.ts b/electron/preload/frameBasics.ts index f8b56738..b19c0684 100644 --- a/electron/preload/frameBasics.ts +++ b/electron/preload/frameBasics.ts @@ -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; diff --git a/electron/preload/frameDesign.ts b/electron/preload/frameDesign.ts index 573f5881..3afc732d 100644 --- a/electron/preload/frameDesign.ts +++ b/electron/preload/frameDesign.ts @@ -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( diff --git a/electron/preload/frameGuide.ts b/electron/preload/frameGuide.ts new file mode 100644 index 00000000..7eef82b3 --- /dev/null +++ b/electron/preload/frameGuide.ts @@ -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