@mosetta/editor 0.0.0-stage → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,66 @@
1
+ // EDITOR TOOLS (ADR-0062): a tool is a BLOCK placed in the workbench shell's `tools` slot. The
2
+ // shell does not render it — it hands the canvases the list of tool nodes (EDITOR_TOOLS_TOKEN);
3
+ // a canvas gives each a button on its bar and, pressed, mounts the node over its stage, in a DI
4
+ // scope holding what the tool works on (CANVAS_TOOL_TOKEN). Both tokens are found by NAME: a tool
5
+ // of another distribution declares them under the same names.
6
+ //
7
+ // A tool node is `lazy`: its module stays out of the workbench pages' preloads and loads when the
8
+ // button is first pressed.
9
+ import { SingleDiToken } from '@mosetta/di';
10
+ import type { EditingSession } from '@mosetta/content';
11
+
12
+ /** a tool as the shell holds it: a node of its `tools` slot */
13
+ export interface IToolNode {
14
+ readonly id: string;
15
+ readonly reference: string;
16
+ readonly slot?: string;
17
+ readonly settings?: Record<string, unknown>;
18
+ }
19
+
20
+ /** the tools the workbench offers — the shell's `tools` slot, as nodes */
21
+ export interface IEditorTools {
22
+ list(): readonly IToolNode[];
23
+ }
24
+
25
+ export const EDITOR_TOOLS_TOKEN = new SingleDiToken<IEditorTools>('MOSETTA_EDITOR_TOOLS');
26
+
27
+ /** what a tool mounted over a canvas works on */
28
+ export interface ICanvasToolContext {
29
+ /** the canvas's session: the draft (or revision) shown, its edits, undo */
30
+ readonly session: EditingSession;
31
+ /** the document as a reader gets it — the canvas's preview address without the editor's agent */
32
+ previewHref(): string;
33
+ /**
34
+ * the font faces the canvas's preview has loaded (its `document.fonts`) — which of the fonts a
35
+ * page declares its text actually uses — once the preview's document is in and its fonts are
36
+ * ready (a tool opened from the address comes before the preview); empty where the frame cannot
37
+ * be read or does not come in time
38
+ */
39
+ loadedFonts(): Promise<readonly { family: string; weight: string; style: string; unicodeRange: string }[]>;
40
+ /**
41
+ * what of the tool is open, in the tool's own words, as it said it last — the workbench keeps it
42
+ * in the address for the document in front, so a reload or a shared link opens the same; null:
43
+ * nothing said yet
44
+ */
45
+ readState(): string | null;
46
+ writeState(state: string | null): void;
47
+ /** put the tool away */
48
+ close(): void;
49
+ }
50
+
51
+ export const CANVAS_TOOL_TOKEN = new SingleDiToken<ICanvasToolContext>('MOSETTA_CANVAS_TOOL');
52
+
53
+ /** the slot of the shell that holds the tools */
54
+ export const TOOLS_SLOT = 'tools';
55
+
56
+ /** the tool nodes among a block's children */
57
+ export function toolNodesOf<T extends IToolNode>(children: readonly T[]): T[] {
58
+ return children.filter((child) => child.slot === TOOLS_SLOT);
59
+ }
60
+
61
+ /** what a tool's button says: its node's `label`, else its block's name */
62
+ export function toolLabelOf(node: IToolNode): string {
63
+ const label = node.settings?.label;
64
+ if (typeof label === 'string' && label.trim()) return label.trim();
65
+ return /_n_(.+?)_v_/.exec(node.reference)?.[1] ?? node.reference;
66
+ }
@@ -0,0 +1,35 @@
1
+ // Which blocks bring a control to the canvas tools (providerChrome.tsx). A provider's control sets
2
+ // the editor's view of that provider, kept by its ADDRESS (`i18n`, or `#prices` for one addressed
3
+ // by id) — not by node: the i18n of a page and the one of its frame prefab are edited in one
4
+ // language, and two controls of it would only move together. So one per address, the first in the
5
+ // tree's order; a block that is no provider (a theme) brings its own.
6
+ import { providerAddressOf } from '@mosetta/runtime/client';
7
+ import type { IRefProviderDeclaration } from '@mosetta/runtime/client';
8
+
9
+ export interface IChromeNode {
10
+ readonly id: string;
11
+ readonly reference: string;
12
+ readonly settings: Record<string, unknown>;
13
+ }
14
+
15
+ export interface IChromeDeclarations {
16
+ readonly provides?: IRefProviderDeclaration;
17
+ readonly editorChrome?: boolean;
18
+ }
19
+
20
+ export function chromeNodes(
21
+ nodes: readonly IChromeNode[],
22
+ declarationsOf: (reference: string) => IChromeDeclarations | null | undefined,
23
+ hasEditor: (reference: string) => boolean,
24
+ ): IChromeNode[] {
25
+ const addresses = new Set<string>();
26
+ return nodes.filter((node) => {
27
+ const declared = declarationsOf(node.reference);
28
+ if (!(declared?.provides || declared?.editorChrome) || !hasEditor(node.reference)) return false;
29
+ if (!declared.provides) return true;
30
+ const address = providerAddressOf(declared.provides, node.id);
31
+ if (addresses.has(address)) return false;
32
+ addresses.add(address);
33
+ return true;
34
+ });
35
+ }
@@ -0,0 +1,33 @@
1
+ // The overlay's GHOSTS — the places a block may go, tabs glued to the selected block's frame
2
+ // (canvas.component.tsx): which edges they go on is read off the boxes the preview reports.
3
+
4
+ /** a box on the overlay, in the preview's viewport coordinates */
5
+ export interface IGhostRect {
6
+ readonly x: number;
7
+ readonly y: number;
8
+ readonly width: number;
9
+ readonly height: number;
10
+ }
11
+
12
+ /** which way the blocks of a slot run: one under another, or side by side */
13
+ export type TAxis = 'column' | 'row';
14
+
15
+ /** the selection's corner tags stand this tall over its top edge (canvas.css, .canvas-view__tag) */
16
+ export const TAG_HEIGHT = 18;
17
+
18
+ /**
19
+ * Which way a block's slot runs, read off the blocks themselves: a neighbour standing BESIDE the
20
+ * block (sharing at least half its height) means a row. Styles are not read — the editor never
21
+ * reads them back (a jsdom page would hang on it) and the geometry already says it. A lone block
22
+ * has no neighbour to tell: it is taken to be in a column, the common case.
23
+ */
24
+ export function axisAmong(boxes: readonly (IGhostRect | undefined)[], index: number): TAxis {
25
+ const self = boxes[index];
26
+ if (!self) return 'column';
27
+ const beside = (a: IGhostRect | undefined, b: IGhostRect | undefined): boolean => {
28
+ if (!a || !b) return false;
29
+ const overlap = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
30
+ return overlap >= Math.min(a.height, b.height) / 2;
31
+ };
32
+ return beside(boxes[index - 1], self) || beside(self, boxes[index + 1]) ? 'row' : 'column';
33
+ }
@@ -0,0 +1,56 @@
1
+ // The INSERT POPOVER: a "+" pressed in the preview names a place, and the blocks that can go there
2
+ // open right beside it — search focused, ready to type. Before, the place was named in the preview
3
+ // and the block picked in a palette tab elsewhere in the workbench; the link between the two was
4
+ // a frame colour, and it was not obvious that the next step lived in another panel.
5
+ //
6
+ // The same picker the palette panel shows (content/blockPicker.tsx). Placed like the settings
7
+ // popover (popoverPlacement.ts) — beside the "+" where there is room, never over it. A pick
8
+ // inserts and spends the place, which closes the popover; Escape or × give the place back.
9
+ import type { EditingSession, IInsertionPoint } from '@mosetta/content';
10
+ import { BlockPicker } from '../content/blockPicker';
11
+ import type { IPopoverPlacement } from './popoverPlacement';
12
+
13
+ /** tall enough for a search and a screenful of blocks; the room there is caps it */
14
+ const INSERT_POPOVER_HEIGHT = 420;
15
+
16
+ export function InsertPopover({ session, point, placement }: {
17
+ session: EditingSession;
18
+ point: IInsertionPoint;
19
+ placement: IPopoverPlacement;
20
+ }) {
21
+ const close = () => session.setInsertionPoint(null);
22
+ const style = {
23
+ left: `${placement.x}px`,
24
+ top: `${placement.y}px`,
25
+ width: `${placement.width}px`,
26
+ height: `${Math.min(INSERT_POPOVER_HEIGHT, placement.maxHeight)}px`,
27
+ ...(placement.fromBottom ? { transform: 'translateY(-100%)' } : {}),
28
+ };
29
+ return (
30
+ <div className="canvas-view__settings canvas-view__insert"
31
+ style={style}
32
+ data-test-id="canvas-insert-popover"
33
+ data-insert-side={placement.side}
34
+ // the canvas's own pointer and key handling is for the page, not for the picker
35
+ onMouseDown={(e) => e.stopPropagation()}>
36
+ <div className="canvas-view__settings-head">
37
+ {/* the place in the preview's own relative words ("add before heading") */}
38
+ <span className="canvas-view__settings-name">{point.label ?? `insert into ${point.parentId}`}</span>
39
+ <button type="button"
40
+ className="canvas-view__settings-action"
41
+ data-test-id="canvas-insert-close"
42
+ data-tooltip="Close — nothing is inserted"
43
+ aria-label="Close the block picker"
44
+ onClick={close}>
45
+ ×
46
+ </button>
47
+ </div>
48
+ <div className="canvas-view__insert-body">
49
+ <BlockPicker session={session}
50
+ point={point}
51
+ autoFocus
52
+ onCancel={close} />
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,104 @@
1
+ // Where the settings popover goes: beside the selected block, on the side with the most room,
2
+ // never over the block itself — the author types and watches the block change. When no side
3
+ // fits (the page root, a block as wide as the page and as tall as the screen), it docks in the
4
+ // top-right corner of the stage and covers what it must. Beside a block it grows to what its
5
+ // content needs: a form taller than the room below the block's top moves up to fit, as far as
6
+ // the stage allows. Plain geometry, in stage pixels.
7
+
8
+ export interface IRect {
9
+ readonly x: number;
10
+ readonly y: number;
11
+ readonly width: number;
12
+ readonly height: number;
13
+ }
14
+
15
+ export interface IPopoverPlacement {
16
+ readonly x: number;
17
+ /** the popover's top edge — or, when `fromBottom`, its bottom edge */
18
+ readonly y: number;
19
+ readonly width: number;
20
+ readonly maxHeight: number;
21
+ readonly fromBottom: boolean;
22
+ readonly side: 'right' | 'left' | 'below' | 'above' | 'dock';
23
+ }
24
+
25
+ export interface IPlacementOptions {
26
+ readonly width?: number;
27
+ /** the distance from the block — negative laps over its frame, so the two join (see seamOf) */
28
+ readonly gap?: number;
29
+ /** the distance above the block, when it differs (the selection's corner tags stand there) */
30
+ readonly gapAbove?: number;
31
+ readonly margin?: number;
32
+ /** the least height worth opening a popover above or below a block */
33
+ readonly minHeight?: number;
34
+ /** stage pixels reserved at the top (the providers' chrome) */
35
+ readonly top?: number;
36
+ /** how tall the content would be with room enough — beside a block, it moves up to fit */
37
+ readonly contentHeight?: number;
38
+ }
39
+
40
+ const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value));
41
+
42
+ export function placePopover(box: IRect | null, stage: { width: number; height: number }, options: IPlacementOptions = {}): IPopoverPlacement {
43
+ const { gap = 8, margin = 8, minHeight = 220, top = margin, contentHeight = 0 } = options;
44
+ const gapAbove = options.gapAbove ?? gap;
45
+ const width = Math.max(200, Math.min(options.width ?? 340, stage.width - 2 * margin));
46
+ const bottomEdge = stage.height - margin;
47
+ const dock: IPopoverPlacement = {
48
+ x: stage.width - margin - width, y: top, width, maxHeight: Math.max(minHeight, bottomEdge - top), fromBottom: false, side: 'dock',
49
+ };
50
+ if (!box) return dock;
51
+
52
+ const roomRight = stage.width - margin - (box.x + box.width + gap);
53
+ const roomLeft = box.x - gap - margin;
54
+ const roomBelow = bottomEdge - (box.y + box.height + gap);
55
+ const roomAbove = box.y - gapAbove - top;
56
+ // beside: level with the block's top — or higher, as far as the content needs — inside the stage
57
+ const besideTop = clamp(Math.min(box.y, bottomEdge - contentHeight), top, Math.max(top, bottomEdge - minHeight));
58
+ if (roomRight >= width || roomLeft >= width) {
59
+ const right = roomRight >= roomLeft;
60
+ return {
61
+ x: right ? box.x + box.width + gap : box.x - gap - width,
62
+ y: besideTop,
63
+ width,
64
+ maxHeight: bottomEdge - besideTop,
65
+ fromBottom: false,
66
+ side: right ? 'right' : 'left',
67
+ };
68
+ }
69
+ // under or over the block, flush with its left edge — the stage's margin gives way to a block
70
+ // standing at the very edge, or the two would join with a step
71
+ const x = clamp(box.x, Math.min(margin, Math.max(0, box.x)), stage.width - margin - width);
72
+ if (Math.max(roomBelow, roomAbove) >= minHeight) {
73
+ return roomBelow >= roomAbove
74
+ ? { x, y: box.y + box.height + gap, width, maxHeight: roomBelow, fromBottom: false, side: 'below' }
75
+ : { x, y: box.y - gapAbove, width, maxHeight: roomAbove, fromBottom: true, side: 'above' };
76
+ }
77
+ return dock;
78
+ }
79
+
80
+ /**
81
+ * Where a popover laid against its block JOINS it: the stretch of the popover's own edge that
82
+ * runs along the block's frame — there the popover draws no border of its own, and its body covers
83
+ * the frame's, so frame and popover read as one piece. In the popover's own pixels along that edge;
84
+ * null when they do not touch (docked, above the block's corner tags).
85
+ */
86
+ export interface ISeam {
87
+ readonly edge: 'top' | 'left' | 'right';
88
+ readonly from: number;
89
+ readonly to: number;
90
+ }
91
+
92
+ export function seamOf(place: IPopoverPlacement, box: IRect): ISeam | null {
93
+ if (place.side === 'below') {
94
+ const from = Math.max(box.x, place.x) - place.x;
95
+ const to = Math.min(box.x + box.width, place.x + place.width) - place.x;
96
+ return to > from ? { edge: 'top', from, to } : null;
97
+ }
98
+ if (place.side === 'right' || place.side === 'left') {
99
+ const from = Math.max(0, box.y - place.y);
100
+ const to = box.y + box.height - place.y;
101
+ return to > from ? { edge: place.side === 'right' ? 'left' : 'right', from, to } : null;
102
+ }
103
+ return null;
104
+ }
@@ -0,0 +1,87 @@
1
+ // The settings popover's size is the author's: a grip in its free corner resizes it, and the
2
+ // size they last asked for is remembered (localStorage) for every popover after. It is a WISH,
3
+ // not a fixed box — placePopover still fits it beside the block, and a popover opened by a
4
+ // screen edge or in the dock corner gets only the room there is. Plain geometry, stage pixels.
5
+ import type { IPopoverPlacement } from './popoverPlacement';
6
+
7
+ export interface IPopoverSize {
8
+ readonly width: number;
9
+ /** null — as tall as the form, up to the room there is */
10
+ readonly height: number | null;
11
+ }
12
+
13
+ export const DEFAULT_POPOVER_SIZE: IPopoverSize = { width: 340, height: null };
14
+ export const MIN_POPOVER_WIDTH = 240;
15
+ export const MIN_POPOVER_HEIGHT = 120;
16
+
17
+ const STORAGE_KEY = 'mosetta:editor:settings-popover-size';
18
+
19
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
20
+
21
+ /** the remembered size; anything unreadable (no storage, a private window, junk) is the default */
22
+ export function readPopoverSize(storage: Pick<Storage, 'getItem'> | null | undefined): IPopoverSize {
23
+ try {
24
+ const raw = storage?.getItem(STORAGE_KEY);
25
+ if (!raw) return DEFAULT_POPOVER_SIZE;
26
+ const parsed = JSON.parse(raw) as { width?: unknown; height?: unknown };
27
+ if (!finite(parsed.width)) return DEFAULT_POPOVER_SIZE;
28
+ return {
29
+ width: Math.max(MIN_POPOVER_WIDTH, parsed.width),
30
+ height: finite(parsed.height) ? Math.max(MIN_POPOVER_HEIGHT, parsed.height) : null,
31
+ };
32
+ } catch {
33
+ return DEFAULT_POPOVER_SIZE;
34
+ }
35
+ }
36
+
37
+ export function writePopoverSize(storage: Pick<Storage, 'setItem'> | null | undefined, size: IPopoverSize): void {
38
+ try {
39
+ storage?.setItem(STORAGE_KEY, JSON.stringify(size));
40
+ } catch {
41
+ // no storage (a private window, a quota) — the size lives until the page reloads
42
+ }
43
+ }
44
+
45
+ /** which corner is free to move: the one away from the block and from the stage edge it hugs */
46
+ export interface IGripCorner {
47
+ readonly horizontal: 'left' | 'right';
48
+ readonly vertical: 'top' | 'bottom';
49
+ }
50
+
51
+ export function gripCornerOf(placement: IPopoverPlacement): IGripCorner {
52
+ return {
53
+ // left of the block, or docked to the right edge: the right edge stays, the left one moves.
54
+ // Above the block the grip rides the TOP edge, and its right end belongs to the close button
55
+ // — there the left edge moves (a popover above or below is only clamped sideways anyway)
56
+ horizontal: placement.side === 'left' || placement.side === 'dock' || placement.fromBottom ? 'left' : 'right',
57
+ // above the block the popover hangs from its bottom edge — the top one moves
58
+ vertical: placement.fromBottom ? 'top' : 'bottom',
59
+ };
60
+ }
61
+
62
+ export interface IResizedBox {
63
+ readonly x: number;
64
+ readonly width: number;
65
+ readonly height: number;
66
+ }
67
+
68
+ /**
69
+ * The box while its grip is dragged by (dx, dy) from where the drag started: the edges opposite
70
+ * the grip stay put, the size follows the pointer within the room the placement had.
71
+ */
72
+ export function resizeBox(
73
+ placement: IPopoverPlacement,
74
+ start: { readonly width: number; readonly height: number },
75
+ delta: { readonly dx: number; readonly dy: number },
76
+ stage: { readonly width: number },
77
+ margin = 8,
78
+ ): IResizedBox {
79
+ const corner = gripCornerOf(placement);
80
+ const right = placement.x + placement.width; // the fixed edge when the grip is on the left
81
+ const maxWidth = corner.horizontal === 'right' ? stage.width - margin - placement.x : right - margin;
82
+ const wanted = start.width + (corner.horizontal === 'right' ? delta.dx : -delta.dx);
83
+ const width = Math.max(Math.min(MIN_POPOVER_WIDTH, maxWidth), Math.min(maxWidth, wanted));
84
+ const tall = start.height + (corner.vertical === 'bottom' ? delta.dy : -delta.dy);
85
+ const height = Math.max(Math.min(MIN_POPOVER_HEIGHT, placement.maxHeight), Math.min(placement.maxHeight, tall));
86
+ return { x: corner.horizontal === 'right' ? placement.x : right - width, width, height };
87
+ }
@@ -0,0 +1,191 @@
1
+ // The CANVAS CONTROLS: what the author may want at hand over the page — the page's own settings,
2
+ // and the STANDING CONTROLS blocks bring (i18n's language being edited, a theme's pick for the
3
+ // canvas) — each behind its own button of the canvas bar: pressed, the control's card opens in
4
+ // the stage's corner; pressed again, it folds. The overlay belongs to the page.
5
+ //
6
+ // A block brings a control when its module says so (`editorChrome`, implied by `provides`) and its
7
+ // editor facet exists: the facet is mounted here with `placement: chrome` and the node's settings
8
+ // as `subject`, through the same mount the settings use for forms. The blocks of the project's
9
+ // prefabs count too — the site's theme lives in its theme prefab. The canvas knows no provider.
10
+ // A provider brings one control per address, not per node (chromeNodes.ts).
11
+ import { ReactNode, useEffect, useMemo, useState } from 'react';
12
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
13
+ import { useComponent, useDi } from '@mosetta/react';
14
+ import { useServerAction } from '@mosetta/react/server-action';
15
+ import type { ICatalogEntry, IBlueprintNodeDoc } from '@mosetta/content';
16
+ import { listComponentsToken } from '../content/contentCommon';
17
+ import { hostedPage, hostFallbackOf } from '../content/mountedTree';
18
+ import { useMountedTree } from '../content/useMountedTree';
19
+ import { formRootOf } from '../content/formRoot';
20
+ import { ensureRefsEditing } from '../content/refsEditing';
21
+ import { chromeNodes } from './chromeNodes';
22
+ import type { IChromeNode } from './chromeNodes';
23
+ import { useProjectPrefabs } from '../content/projectPrefabs';
24
+
25
+ function ProviderChrome({ node, rootReference }: { node: IChromeNode; rootReference: string }) {
26
+ const fallback = hostFallbackOf(useComponent().reference);
27
+ // the node is rebuilt on every render of the tools: the mount follows what it IS
28
+ const subject = JSON.stringify(node.settings);
29
+ const spec = useMemo(() => ({
30
+ rootReference,
31
+ tree: hostedPage(rootReference, [{
32
+ id: `chrome-${node.id}`,
33
+ reference: node.reference.replace('_t_component_', '_t_editor_'),
34
+ settings: { placement: 'chrome', subject: node.settings, subjectId: node.id },
35
+ children: [],
36
+ }], fallback),
37
+ }), [node.id, node.reference, subject, rootReference, fallback]);
38
+ const { ref, failed } = useMountedTree(spec);
39
+ if (failed) return null;
40
+ return <div ref={ref}
41
+ className="canvas-view__chrome-item"
42
+ data-provider-chrome={node.id} />;
43
+ }
44
+
45
+ /** every node of the tree, the blocks of the prefabs it places included (with their expanded ids) */
46
+ function nodesWithPrefabs(doc: IBlueprintNodeDoc, treeOf: (reference: string) => IBlueprintNodeDoc | null): IChromeNode[] {
47
+ const out: IChromeNode[] = [];
48
+ const walk = (node: IBlueprintNodeDoc, prefix: string) => {
49
+ out.push({ id: `${prefix}${node.id}`, reference: node.reference, settings: node.settings ?? {} });
50
+ const tree = prefix ? null : treeOf(node.reference);
51
+ for (const inner of tree?.children ?? []) walk(inner, `${node.id}/`);
52
+ for (const child of node.children ?? []) walk(child, prefix);
53
+ };
54
+ walk(doc, '');
55
+ return out;
56
+ }
57
+
58
+ const GlobeIcon = (
59
+ <svg viewBox="0 0 16 16"
60
+ aria-hidden="true">
61
+ <circle cx="8"
62
+ cy="8"
63
+ r="5.8"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ strokeWidth="1.4" />
67
+ <path d="M2.2 8h11.6M8 2.2c-2 2.2-2 9.4 0 11.6M8 2.2c2 2.2 2 9.4 0 11.6"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ strokeWidth="1.2" />
71
+ </svg>
72
+ );
73
+
74
+ const PaletteIcon = (
75
+ <svg viewBox="0 0 16 16"
76
+ aria-hidden="true">
77
+ <path d="M8 2.2a5.8 5.8 0 1 0 0 11.6c1 0 1.4-.6 1.1-1.3-.4-.9.2-1.7 1.2-1.7h1.2A2.3 2.3 0 0 0 13.8 8.5 5.8 5.8 0 0 0 8 2.2z"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ strokeWidth="1.4"
81
+ strokeLinejoin="round" />
82
+ <circle cx="5.6"
83
+ cy="7"
84
+ r="1"
85
+ fill="currentColor" />
86
+ <circle cx="8"
87
+ cy="5.2"
88
+ r="1"
89
+ fill="currentColor" />
90
+ <circle cx="10.6"
91
+ cy="6.6"
92
+ r="1"
93
+ fill="currentColor" />
94
+ </svg>
95
+ );
96
+
97
+ const SlidersIcon = (
98
+ <svg viewBox="0 0 16 16"
99
+ aria-hidden="true">
100
+ <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ strokeWidth="1.4"
104
+ strokeLinecap="round" />
105
+ <circle cx="6"
106
+ cy="4.5"
107
+ r="1.5"
108
+ fill="#ffffff"
109
+ stroke="currentColor"
110
+ strokeWidth="1.3" />
111
+ <circle cx="10.5"
112
+ cy="8"
113
+ r="1.5"
114
+ fill="#ffffff"
115
+ stroke="currentColor"
116
+ strokeWidth="1.3" />
117
+ <circle cx="5"
118
+ cy="11.5"
119
+ r="1.5"
120
+ fill="#ffffff"
121
+ stroke="currentColor"
122
+ strokeWidth="1.3" />
123
+ </svg>
124
+ );
125
+
126
+ /** the page's own settings: a sheet with its lines */
127
+ export const PageSettingsIcon = (
128
+ <svg viewBox="0 0 16 16"
129
+ aria-hidden="true">
130
+ <path d="M4 2.5h5.5L12.5 5.5V13.5H4z"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ strokeWidth="1.4"
134
+ strokeLinejoin="round" />
135
+ <path d="M9.5 2.5v3h3M6 8.5h4.5M6 10.8h4.5"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ strokeWidth="1.3" />
139
+ </svg>
140
+ );
141
+
142
+ const shortName = (reference: string): string => /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
143
+
144
+ /**
145
+ * A control's glyph and words for the bar, by what its module is: the canvas knows no provider,
146
+ * but a language and a theme are what blocks bring today, and a globe and a palette read at
147
+ * once; anything else wears sliders and its own name.
148
+ */
149
+ export function chromeFaceOf(node: IChromeNode): { icon: ReactNode; label: string } {
150
+ const name = shortName(node.reference);
151
+ if (/^(i18n|lang|languages?)$/i.test(name)) return { icon: GlobeIcon, label: 'The language being edited' };
152
+ if (/^theme/i.test(name)) return { icon: PaletteIcon, label: 'The theme shown on this canvas' };
153
+ return { icon: SlidersIcon, label: `The ${name} controls` };
154
+ }
155
+
156
+ /**
157
+ * The standing controls of the document's blocks (the prefabs it places included), each with the
158
+ * root to mount its facet under. Recomputed as modules arrive — a control may appear late.
159
+ */
160
+ export function useChromeNodes(doc: IBlueprintNodeDoc | null): { chromes: readonly IChromeNode[]; rootOf: (node: IChromeNode) => string } {
161
+ const di = useDi(DI_CONTAINER_TOKEN);
162
+ const self = useComponent().reference;
163
+ const [, setLoads] = useState(0);
164
+ const refs = ensureRefsEditing(di);
165
+ // a module the realm was loading arrived — a control may have appeared
166
+ useEffect(() => refs.subscribe(() => setLoads((n) => n + 1)), [refs]);
167
+ const project = useProjectPrefabs();
168
+ // shares the pages view's and the picker's cache entry — no extra request
169
+ const catalog = useServerAction({ token: listComponentsToken, input: {} });
170
+ const components: readonly ICatalogEntry[] = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.components : [];
171
+ const chromes = doc
172
+ ? chromeNodes(
173
+ nodesWithPrefabs(doc, (reference) => project.treeOf(reference)?.doc ?? null),
174
+ (reference) => refs.declarations(reference),
175
+ (reference) => components.find((c) => c.id === reference)?.hasEditor === true,
176
+ )
177
+ : [];
178
+ return { chromes, rootOf: (node) => formRootOf(components, node.reference, self) };
179
+ }
180
+
181
+ /** the open control's card, in the stage's corner */
182
+ export function ChromeCard({ node, rootReference }: { node: IChromeNode; rootReference: string }) {
183
+ return (
184
+ <div className="canvas-view__chrome"
185
+ data-test-id="canvas-provider-chrome"
186
+ data-chrome-open={node.id}>
187
+ <ProviderChrome node={node}
188
+ rootReference={rootReference} />
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,43 @@
1
+ // Whether the previewed page is being scrolled: from its first move until a fresh measurement
2
+ // arrives (the preview's agent measures once the page settles) — or, should none come (a
3
+ // cross-origin preview, a stalled agent), until a lull. It tells only the START and the END:
4
+ // a scroll moves the page every frame, and a state change per frame is what makes an editor lag.
5
+ export interface IScrollWatch {
6
+ /** the page moved */
7
+ moved(): void;
8
+ /** a fresh measurement arrived: the scroll is over */
9
+ settled(): void;
10
+ dispose(): void;
11
+ }
12
+
13
+ export function scrollWatch(onChange: (scrolling: boolean) => void, lullMs: number, timers: Pick<typeof globalThis, 'setTimeout' | 'clearTimeout'> = globalThis): IScrollWatch {
14
+ let moving = false;
15
+ let lull: ReturnType<typeof setTimeout> | null = null;
16
+ const settled = (): void => {
17
+ if (lull !== null) timers.clearTimeout(lull);
18
+ lull = null;
19
+ if (!moving) return;
20
+ moving = false;
21
+ onChange(false);
22
+ };
23
+ return {
24
+ moved() {
25
+ if (!moving) {
26
+ moving = true;
27
+ onChange(true);
28
+ }
29
+ if (lull !== null) timers.clearTimeout(lull);
30
+ lull = timers.setTimeout(settled, lullMs);
31
+ },
32
+ settled,
33
+ dispose() {
34
+ if (lull !== null) timers.clearTimeout(lull);
35
+ lull = null;
36
+ },
37
+ };
38
+ }
39
+
40
+ /** a box out of the stage's sight: wholly above, below or beside it */
41
+ export function outOfSight(box: { x: number; y: number; width: number; height: number }, stage: { width: number; height: number }): boolean {
42
+ return box.y + box.height <= 0 || box.y >= stage.height || box.x + box.width <= 0 || box.x >= stage.width;
43
+ }