@mosetta/editor 0.0.0-stage → 0.0.28
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.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,2038 @@
|
|
|
1
|
+
// Smart widget: the PREVIEW — an IFRAME onto the page's real path + ?previewToken, and the
|
|
2
|
+
// SELECTION OVERLAY drawn on top of it.
|
|
3
|
+
// Full realm isolation by design (the user's constructor experience: shared modules + shared
|
|
4
|
+
// memory inevitably collide): the iframe runs the REAL bootstrap with its own System/react/DI/
|
|
5
|
+
// caches, and the same URL is shareable with other observers.
|
|
6
|
+
//
|
|
7
|
+
// The edited tree reaches it LIVE, over the same channel: the preview's page manager is handed the
|
|
8
|
+
// new tree and the application re-renders around it. The frame is rebuilt only when a block needs
|
|
9
|
+
// a module the running realm never loaded — everything else, including every keystroke in the
|
|
10
|
+
// settings panel, arrives without a reload.
|
|
11
|
+
//
|
|
12
|
+
// The overlay lives HERE, in the editor's own document — never inside the previewed application,
|
|
13
|
+
// whose UI and stacking context it would otherwise have to fight. It is a pointer-transparent
|
|
14
|
+
// layer: hovering and clicking happen in the iframe, where the agent hit-tests the real DOM and
|
|
15
|
+
// reports back over a MessageChannel. Boxes arrive in the iframe's viewport coordinates, which is
|
|
16
|
+
// exactly this layer's coordinate space.
|
|
17
|
+
import { defineComponentSide, markInternalTile } from '@mosetta/react';
|
|
18
|
+
import { useComponent, useIsPreviewMode } from '@mosetta/react';
|
|
19
|
+
import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
20
|
+
import type { ReactNode } from 'react';
|
|
21
|
+
import { DEFAULT_SLOT, editingSessionHubOf, findNodeDoc, IInsertionPoint, IBlueprintNodeDoc, IPageDoc, unitOf } from '@mosetta/content';
|
|
22
|
+
import type { ContentKind } from '@mosetta/content';
|
|
23
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
24
|
+
import { EServerActionStart } from '@mosetta/server-action/client';
|
|
25
|
+
import { createPageDraftToken, listPagesToken, publishPageToken } from '../content/contentCommon';
|
|
26
|
+
import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
|
|
27
|
+
import { PublishModal } from '../content/publishModal';
|
|
28
|
+
import { UpdatesModal, upgradeKey } from '../content/updatesModal';
|
|
29
|
+
import { upgradeChanges, upgradeGroups, useUpgrades, withLatestTools } from '../content/blockUpgrades';
|
|
30
|
+
import '../content/workbenchScale.css';
|
|
31
|
+
import './canvas.css';
|
|
32
|
+
import { useCanvasEditingSession } from '../content/useEditingSession';
|
|
33
|
+
import { useCtrlHeld } from '../content/useCtrlHeld';
|
|
34
|
+
import { parseBlocks, serializeBlocks } from '../content/editingClipboard';
|
|
35
|
+
import { blocksLabel, noticesOf } from '../content/editingNotices';
|
|
36
|
+
import { ensureEditorViews, ensureSettingsDraft } from '../content/settingsDraft';
|
|
37
|
+
import { useFeedProjectConfig } from '../content/projectConfig';
|
|
38
|
+
import { ChromeCard, chromeFaceOf, PageSettingsIcon, useChromeNodes } from './providerChrome';
|
|
39
|
+
import type { IChromeNode } from './chromeNodes';
|
|
40
|
+
import { SettingsPopover } from './settingsPopover';
|
|
41
|
+
import { EDITOR_TOOLS_TOKEN, toolLabelOf } from './canvasTools';
|
|
42
|
+
import { ToolPanel } from './toolPanel';
|
|
43
|
+
import type { IPopoverContent } from './settingsPopover';
|
|
44
|
+
import { InsertPopover } from './insertPopover';
|
|
45
|
+
import { TooltipHost } from '../content/tooltips';
|
|
46
|
+
import { placePopover, seamOf } from './popoverPlacement';
|
|
47
|
+
import { outOfSight, scrollWatch } from './scrollWatch';
|
|
48
|
+
import { axisAmong, TAG_HEIGHT, type TAxis } from './ghosts';
|
|
49
|
+
import { readPopoverSize, writePopoverSize, type IPopoverSize } from './popoverSize';
|
|
50
|
+
import { ensureSettingsPanels } from '../content/nodeSettings';
|
|
51
|
+
import { ensureRefsEditing } from '../content/refsEditing';
|
|
52
|
+
import { watchArrivals } from '../content/autoBind';
|
|
53
|
+
import { useDi, useDiOptional } from '@mosetta/react';
|
|
54
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
55
|
+
import { DOCK_TAB_TOKEN } from '@mosetta/ui';
|
|
56
|
+
import { WORKSPACE_TOKEN } from '../workbench/workspace';
|
|
57
|
+
import { useFeedPrefabTrees, useProjectPrefabs } from '../content/projectPrefabs';
|
|
58
|
+
import { isPartId, prefabPartOf } from '../content/prefabParts';
|
|
59
|
+
import {
|
|
60
|
+
INodeBox,
|
|
61
|
+
isPreviewHello,
|
|
62
|
+
ISlotBox,
|
|
63
|
+
LANDING_NODE_ID,
|
|
64
|
+
PREVIEW_PORT,
|
|
65
|
+
PREVIEW_PROTOCOL_VERSION,
|
|
66
|
+
previewBridgeIdOf,
|
|
67
|
+
TPreviewCommand,
|
|
68
|
+
TPreviewEvent,
|
|
69
|
+
} from '../previewBridge/previewBridgeCommon';
|
|
70
|
+
|
|
71
|
+
const shortName = (reference: string) => /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
|
|
72
|
+
|
|
73
|
+
const noSubscription = (): (() => void) => () => undefined;
|
|
74
|
+
const noState = (): null => null;
|
|
75
|
+
|
|
76
|
+
/** how a place is named on screen — the block that opened it and the slot, default one included */
|
|
77
|
+
const slotLabel = (owner: string, slot: string) => `${owner}#${slot}`;
|
|
78
|
+
|
|
79
|
+
/** the window's localStorage, or null where reading it throws (blocked site data, sandboxing) */
|
|
80
|
+
function localStorageOrNull(): Storage | null {
|
|
81
|
+
try {
|
|
82
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** every module a tree names — the set a realm has to hold already to be able to render it */
|
|
89
|
+
function referencesOf(node: IBlueprintNodeDoc | IPageDoc | null): string[] {
|
|
90
|
+
if (!node) return [];
|
|
91
|
+
return (node.children ?? []).reduce<string[]>(
|
|
92
|
+
(all, child) => [...all, ...referencesOf(child)],
|
|
93
|
+
[node.reference],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** the ids of the PREFAB nodes a tree places — the ones a live update can only re-expand from what is shown */
|
|
98
|
+
function unitNodeIds(node: IBlueprintNodeDoc | IPageDoc): string[] {
|
|
99
|
+
return (node.children ?? []).reduce<string[]>(
|
|
100
|
+
(all, child) => [...all, ...(unitOf(child.reference) ? [child.id] : []), ...unitNodeIds(child)],
|
|
101
|
+
[],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** the node holding `id`, or null for the root (and for an id the document does not know) */
|
|
106
|
+
function parentOfNode(root: IPageDoc | IBlueprintNodeDoc, id: string): IBlueprintNodeDoc | null {
|
|
107
|
+
for (const child of root.children ?? []) {
|
|
108
|
+
if (child.id === id) return root;
|
|
109
|
+
const deeper = parentOfNode(child, id);
|
|
110
|
+
if (deeper) return deeper;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The dock's "bring this panel forward" event. Spelled out rather than imported: the dock is a
|
|
116
|
+
// block of another system (@mosetta/ui-kit-react) and the editor does not depend on it — the two
|
|
117
|
+
// meet at a window event, which is the whole point of the dock exposing one.
|
|
118
|
+
|
|
119
|
+
/** how often a panel being dragged is worth a fresh measurement of the page inside */
|
|
120
|
+
const RESIZE_MEASURE_MS = 120;
|
|
121
|
+
|
|
122
|
+
interface ICanvasSettings {
|
|
123
|
+
/**
|
|
124
|
+
* IGNORED since 2026-09-26: a "+" opens the canvas's own insert popover instead of bringing a
|
|
125
|
+
* palette panel forward. Kept in the type — pages composed before carry the value.
|
|
126
|
+
*/
|
|
127
|
+
readonly paletteSlot?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface IPreviewLink {
|
|
131
|
+
/**
|
|
132
|
+
* The page is being scrolled: from its first move until a fresh measurement arrives (the agent
|
|
133
|
+
* measures once the page settles). Meanwhile what sits OUTSIDE the sliding layer — the settings
|
|
134
|
+
* card — stands where the page no longer is.
|
|
135
|
+
*/
|
|
136
|
+
readonly scrolling: boolean;
|
|
137
|
+
readonly boxes: readonly INodeBox[];
|
|
138
|
+
readonly slots: readonly ISlotBox[];
|
|
139
|
+
/** the prefab nodes the realm shows expanded — null while no agent has said */
|
|
140
|
+
readonly units: ReadonlySet<string> | null;
|
|
141
|
+
/** the previewed page's own viewport — what "the right side of the page" means */
|
|
142
|
+
readonly viewport: { readonly width: number; readonly height: number } | null;
|
|
143
|
+
readonly hoveredId: string | null;
|
|
144
|
+
/** …and the prefab's own block under the pointer, when it is over one (prefabParts.ts) */
|
|
145
|
+
readonly hoveredPart: string | null;
|
|
146
|
+
/** the PREFABS' own blocks, each on its own — `boxes` has each prefab as one box */
|
|
147
|
+
readonly parts: readonly INodeBox[];
|
|
148
|
+
/** the Ctrl/Cmd state the hover was REPORTED with — the preview realm's side of the modifier */
|
|
149
|
+
readonly hoveredCtrl: boolean;
|
|
150
|
+
readonly connected: boolean;
|
|
151
|
+
readonly command: (command: TPreviewCommand) => void;
|
|
152
|
+
/** the frame itself — its size changes are the editor's own business, see useRemeasureOnResize */
|
|
153
|
+
readonly stageRef: RefObject<HTMLDivElement | null>;
|
|
154
|
+
/** the sliding layer INSIDE the clipping surface; scrolling moves it straight in the DOM */
|
|
155
|
+
readonly overlayRef: RefObject<HTMLDivElement | null>;
|
|
156
|
+
/** the frame itself — read every animation frame to follow its scroll without a message */
|
|
157
|
+
readonly frameRef: RefObject<HTMLIFrameElement | null>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Geometry is only true while the frame is on screen at its final size. Panels get closed, resized
|
|
162
|
+
* and re-laid out around it, and none of that reaches the agent inside — so the editor watches its
|
|
163
|
+
* own stage and asks for a fresh measurement whenever it changes.
|
|
164
|
+
*/
|
|
165
|
+
function useRemeasureOnResize(
|
|
166
|
+
stage: RefObject<HTMLDivElement | null>,
|
|
167
|
+
connected: boolean,
|
|
168
|
+
command: (command: TPreviewCommand) => void,
|
|
169
|
+
): void {
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const element = stage.current;
|
|
172
|
+
if (!element || !connected || typeof ResizeObserver === 'undefined') return undefined;
|
|
173
|
+
// Dragging a panel edge fires this every frame, and every one of them costs the preview a full
|
|
174
|
+
// measurement of the page — while the browser is re-laying that page out anyway. So: one
|
|
175
|
+
// measurement at the start of a drag, one after it stops, nothing in between. The outlines are
|
|
176
|
+
// briefly out of date mid-drag, which is the moment nobody is aiming at them.
|
|
177
|
+
let trailing: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
+
let last = 0;
|
|
179
|
+
const observer = new ResizeObserver(() => {
|
|
180
|
+
const now = performance.now();
|
|
181
|
+
if (now - last > RESIZE_MEASURE_MS) {
|
|
182
|
+
last = now;
|
|
183
|
+
command({ t: 'measure' });
|
|
184
|
+
}
|
|
185
|
+
if (trailing !== null) clearTimeout(trailing);
|
|
186
|
+
trailing = setTimeout(() => {
|
|
187
|
+
last = performance.now();
|
|
188
|
+
command({ t: 'measure' });
|
|
189
|
+
}, RESIZE_MEASURE_MS);
|
|
190
|
+
});
|
|
191
|
+
observer.observe(element);
|
|
192
|
+
return () => {
|
|
193
|
+
if (trailing !== null) clearTimeout(trailing);
|
|
194
|
+
observer.disconnect();
|
|
195
|
+
};
|
|
196
|
+
}, [stage, connected, command]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** no measurement after a scroll (a cross-origin preview, a stalled agent): the scroll ends after this lull */
|
|
200
|
+
const SCROLL_LULL_MS = 600;
|
|
201
|
+
|
|
202
|
+
/** a slot with room takes another block; a full one offers nothing */
|
|
203
|
+
const hasRoom = (slot: ISlotBox): boolean => slot.max === 'many' || slot.filled < slot.max;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The editor's end of the preview conversation: waits for the agent's greeting, hands it one end
|
|
207
|
+
* of a fresh MessageChannel, and keeps the geometry it reports. Re-runs whenever the iframe is
|
|
208
|
+
* replaced (a new src/key means a new realm and a new agent).
|
|
209
|
+
*/
|
|
210
|
+
function usePreviewLink(
|
|
211
|
+
frameKey: string,
|
|
212
|
+
onPick: (nodeId: string | null, ctrl: boolean, part: string | null) => void,
|
|
213
|
+
onHover: (nodeId: string | null, part: string | null) => void,
|
|
214
|
+
onPointer: (x: number, y: number) => void,
|
|
215
|
+
): IPreviewLink {
|
|
216
|
+
const [boxes, setBoxes] = useState<readonly INodeBox[]>([]);
|
|
217
|
+
const [parts, setParts] = useState<readonly INodeBox[]>([]);
|
|
218
|
+
const [slots, setSlots] = useState<readonly ISlotBox[]>([]);
|
|
219
|
+
// the prefab nodes the realm shows expanded; null until an agent says (or from one that cannot)
|
|
220
|
+
const [units, setUnits] = useState<ReadonlySet<string> | null>(null);
|
|
221
|
+
const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null);
|
|
222
|
+
const [hovered, setHovered] = useState<{ id: string | null; part: string | null; ctrl: boolean }>({ id: null, part: null, ctrl: false });
|
|
223
|
+
const [connected, setConnected] = useState(false);
|
|
224
|
+
const [scrolling, setScrolling] = useState(false);
|
|
225
|
+
const portRef = useRef<MessagePort | null>(null);
|
|
226
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
227
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
228
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
229
|
+
// where the page stood when the boxes were measured — every scroll is read against it
|
|
230
|
+
const scrollBase = useRef({ x: 0, y: 0 });
|
|
231
|
+
// the pick handler changes with the session; the port must not be reopened for that
|
|
232
|
+
const pickRef = useRef(onPick);
|
|
233
|
+
pickRef.current = onPick;
|
|
234
|
+
const hoverRef = useRef(onHover);
|
|
235
|
+
hoverRef.current = onHover;
|
|
236
|
+
const pointerRef = useRef(onPointer);
|
|
237
|
+
pointerRef.current = onPointer;
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
setBoxes([]);
|
|
241
|
+
setParts([]);
|
|
242
|
+
setSlots([]);
|
|
243
|
+
setViewport(null);
|
|
244
|
+
setHovered({ id: null, part: null, ctrl: false });
|
|
245
|
+
setConnected(false);
|
|
246
|
+
setScrolling(false);
|
|
247
|
+
|
|
248
|
+
// the page moved: scrolling until the measurement that follows it (scrollWatch.ts)
|
|
249
|
+
const scroll = scrollWatch(setScrolling, SCROLL_LULL_MS);
|
|
250
|
+
|
|
251
|
+
let slid = { x: 0, y: 0 };
|
|
252
|
+
const slide = (x: number, y: number): boolean => {
|
|
253
|
+
const layer = overlayRef.current;
|
|
254
|
+
if (!layer || (slid.x === x && slid.y === y)) return false; // nothing moved: no style to invalidate
|
|
255
|
+
slid = { x, y };
|
|
256
|
+
const { x: baseX, y: baseY } = scrollBase.current;
|
|
257
|
+
// written straight to the node: a react state update per scroll event would re-render every
|
|
258
|
+
// outline and every control, and that is precisely the cost that shows up as lag
|
|
259
|
+
layer.style.transform = x === baseX && y === baseY
|
|
260
|
+
? ''
|
|
261
|
+
: `translate(${baseX - x}px, ${baseY - y}px)`;
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The outlines have to move with the page, and a message cannot do that in time: the frame
|
|
267
|
+
* scrolls, its scroll event fires a frame later, the message arrives a task after that, and by
|
|
268
|
+
* then the page has moved again — the visible trailing.
|
|
269
|
+
*
|
|
270
|
+
* A SAME-ORIGIN frame can simply be read: once per animation frame, take its scroll position
|
|
271
|
+
* and slide the layer. Nothing is asked of the preview and nothing crosses the channel, so the
|
|
272
|
+
* overlay lands in the same frame the browser paints the scroll in. A cross-origin preview —
|
|
273
|
+
* a tenant's own subdomain, one day — cannot be read, and falls back to the messages below.
|
|
274
|
+
*/
|
|
275
|
+
// window.requestAnimationFrame, not the bare global: this module reads and drives ANOTHER
|
|
276
|
+
// realm's frame, so it takes its timing from the window it was given (same lesson as building
|
|
277
|
+
// an event with the target's constructor)
|
|
278
|
+
let frameLoop = 0;
|
|
279
|
+
const follow = (): void => {
|
|
280
|
+
frameLoop = window.requestAnimationFrame(follow);
|
|
281
|
+
const view = frameRef.current?.contentWindow;
|
|
282
|
+
if (!view) return;
|
|
283
|
+
try {
|
|
284
|
+
if (slide(view.scrollX, view.scrollY)) scroll.moved();
|
|
285
|
+
} catch {
|
|
286
|
+
window.cancelAnimationFrame(frameLoop); // cross-origin: the messages take over
|
|
287
|
+
frameLoop = 0;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
frameLoop = window.requestAnimationFrame(follow);
|
|
291
|
+
const onEvent = (event: TPreviewEvent): void => {
|
|
292
|
+
if (event.t === 'geometry') {
|
|
293
|
+
scrollBase.current = event.scroll;
|
|
294
|
+
slid = { x: NaN, y: NaN }; // the baseline moved — whatever is on the layer is stale
|
|
295
|
+
slide(event.scroll.x, event.scroll.y);
|
|
296
|
+
setBoxes(event.boxes);
|
|
297
|
+
setParts(event.parts ?? []);
|
|
298
|
+
setSlots(event.slots);
|
|
299
|
+
if (event.units) setUnits(new Set(event.units));
|
|
300
|
+
setViewport(event.viewport);
|
|
301
|
+
scroll.settled();
|
|
302
|
+
} else if (event.t === 'scroll') {
|
|
303
|
+
if (slide(event.x, event.y)) scroll.moved();
|
|
304
|
+
} else if (event.t === 'hover') {
|
|
305
|
+
setHovered({ id: event.nodeId, part: event.part ?? null, ctrl: event.ctrl ?? false });
|
|
306
|
+
hoverRef.current(event.nodeId, event.part ?? null);
|
|
307
|
+
} else if (event.t === 'pointer') {
|
|
308
|
+
// read against the measurement's own baseline — the space every box on the layer is in
|
|
309
|
+
pointerRef.current(event.x + event.scroll.x - scrollBase.current.x, event.y + event.scroll.y - scrollBase.current.y);
|
|
310
|
+
} else if (event.t === 'pick') pickRef.current(event.nodeId, event.ctrl ?? false, event.part ?? null);
|
|
311
|
+
};
|
|
312
|
+
const onGreeting = (event: MessageEvent): void => {
|
|
313
|
+
if (!isPreviewHello(event.data) || !event.source) return;
|
|
314
|
+
// ONLY a greeting from THIS canvas's own frame: hellos arrive on the shared window, and
|
|
315
|
+
// with several canvases mounted an unguarded handler would close its own channel and adopt
|
|
316
|
+
// a SIBLING's agent — one dead overlay, one drawing a stranger's geometry
|
|
317
|
+
if (event.source !== frameRef.current?.contentWindow) return;
|
|
318
|
+
// A greeting always means a FRESH agent, so it always gets a fresh channel. The iframe can
|
|
319
|
+
// reload without its src changing — moving it in the DOM is enough, which is exactly what a
|
|
320
|
+
// dock re-layout does — and holding on to the previous port would leave the editor talking
|
|
321
|
+
// to a realm that no longer exists.
|
|
322
|
+
portRef.current?.close();
|
|
323
|
+
const channel = new MessageChannel();
|
|
324
|
+
channel.port1.onmessage = (message: MessageEvent) => onEvent(message.data as TPreviewEvent);
|
|
325
|
+
channel.port1.start();
|
|
326
|
+
portRef.current = channel.port1;
|
|
327
|
+
// the greeting's own origin is the honest target; a sandboxed realm reports "null", which
|
|
328
|
+
// is not a usable targetOrigin — those get the wildcard, and the port keeps the
|
|
329
|
+
// conversation private either way
|
|
330
|
+
const targetOrigin = event.origin && event.origin !== 'null' ? event.origin : '*';
|
|
331
|
+
(event.source).postMessage(
|
|
332
|
+
{ mosetta: PREVIEW_PORT, protocol: PREVIEW_PROTOCOL_VERSION },
|
|
333
|
+
{ targetOrigin, transfer: [channel.port2] },
|
|
334
|
+
);
|
|
335
|
+
setConnected(true);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
window.addEventListener('message', onGreeting);
|
|
339
|
+
return () => {
|
|
340
|
+
window.removeEventListener('message', onGreeting);
|
|
341
|
+
if (frameLoop) window.cancelAnimationFrame(frameLoop);
|
|
342
|
+
scroll.dispose();
|
|
343
|
+
portRef.current?.close();
|
|
344
|
+
portRef.current = null;
|
|
345
|
+
};
|
|
346
|
+
}, [frameKey]);
|
|
347
|
+
|
|
348
|
+
const command = useCallback((message: TPreviewCommand) => portRef.current?.postMessage(message), []);
|
|
349
|
+
useRemeasureOnResize(stageRef, connected, command);
|
|
350
|
+
return {
|
|
351
|
+
scrolling,
|
|
352
|
+
boxes,
|
|
353
|
+
units,
|
|
354
|
+
slots,
|
|
355
|
+
viewport,
|
|
356
|
+
hoveredId: hovered.id,
|
|
357
|
+
hoveredPart: hovered.part,
|
|
358
|
+
parts,
|
|
359
|
+
hoveredCtrl: hovered.ctrl,
|
|
360
|
+
connected,
|
|
361
|
+
command,
|
|
362
|
+
stageRef,
|
|
363
|
+
overlayRef,
|
|
364
|
+
frameRef,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** the least room between the name over a frame's left end and the chip over its right end */
|
|
369
|
+
const NAME_TAG_GAP = 8;
|
|
370
|
+
|
|
371
|
+
// The INSPECTOR's vocabulary (docs/code-conventions: structural naming): every rect it draws is
|
|
372
|
+
// an "inspector box" — select (blue), pick (yellow — EVERY picked element wears it, the branch
|
|
373
|
+
// root thick and the covered ones thin), and the hover twins. The green "child box" is not here:
|
|
374
|
+
// that name belongs to the container frame an insert lights up (TargetFrame below).
|
|
375
|
+
function Outline({ box, kind, label, size, onSettings, settingsOpen, settingsLoading, onRemove, within, ghost }: {
|
|
376
|
+
box: INodeBox;
|
|
377
|
+
kind: 'hover' | 'select' | 'hover-pick' | 'pick' | 'pick-covered';
|
|
378
|
+
/** the block's name — dropped on a block too short to carry it without covering itself */
|
|
379
|
+
label?: string;
|
|
380
|
+
size?: boolean;
|
|
381
|
+
/** the block's settings, folded behind a button until asked for */
|
|
382
|
+
onSettings?: () => void;
|
|
383
|
+
settingsOpen?: boolean;
|
|
384
|
+
/** asked for and still loading: the button turns while the card waits unseen */
|
|
385
|
+
settingsLoading?: boolean;
|
|
386
|
+
/** given for a block that may leave the page — the root may not, and gets no button */
|
|
387
|
+
onRemove?: () => void;
|
|
388
|
+
/** the page's viewport: a block wider than it keeps its tags INSIDE, where they can be read */
|
|
389
|
+
within?: { readonly width: number };
|
|
390
|
+
/**
|
|
391
|
+
* The place next to the block, GLUED to its frame as if cut from one sheet with it: on top it is
|
|
392
|
+
* a segment of the corner strip, beside the size tag; below, a tab hanging from the bottom edge
|
|
393
|
+
* where the strip stands above; left and right, tabs on those edges.
|
|
394
|
+
*/
|
|
395
|
+
ghost?: { readonly place: 'top' | 'bottom' | 'left' | 'right'; readonly node: ReactNode };
|
|
396
|
+
}) {
|
|
397
|
+
// a block that runs past the page's edges (a full-bleed band, an overflowing root) would carry
|
|
398
|
+
// its corner tags out of sight with it — they stay at the visible edge instead
|
|
399
|
+
const pastRight = within ? Math.max(0, box.x + box.width - within.width) : 0;
|
|
400
|
+
const pastLeft = Math.max(0, -box.x);
|
|
401
|
+
// …and the corner chip of a block NARROWER than the chip, near the page's left edge, would hang
|
|
402
|
+
// off that edge: it is measured, and turns to start at the block's left instead
|
|
403
|
+
const cornerRef = useRef<HTMLDivElement>(null);
|
|
404
|
+
// the NAME is always shown: on its own over the frame's left end while the strip above has room
|
|
405
|
+
// for it beside the chip, otherwise as the chip's first segment (a narrow or short block alike) —
|
|
406
|
+
// measured: the chip without the name, and the name, whichever place it took
|
|
407
|
+
const nameRef = useRef<HTMLSpanElement>(null);
|
|
408
|
+
const chipNameRef = useRef<HTMLSpanElement>(null);
|
|
409
|
+
const [cornerWidth, setCornerWidth] = useState(0);
|
|
410
|
+
const [nameWidth, setNameWidth] = useState(0);
|
|
411
|
+
useLayoutEffect(() => {
|
|
412
|
+
const chipName = chipNameRef.current?.getBoundingClientRect().width ?? 0;
|
|
413
|
+
const width = (cornerRef.current?.getBoundingClientRect().width ?? 0) - chipName;
|
|
414
|
+
const name = chipName || (nameRef.current?.getBoundingClientRect().width ?? 0);
|
|
415
|
+
setCornerWidth((prev) => (Math.abs(prev - width) < 1 ? prev : width));
|
|
416
|
+
setNameWidth((prev) => (Math.abs(prev - name) < 1 ? prev : name));
|
|
417
|
+
});
|
|
418
|
+
const nameInChip = Boolean(label && size) && nameWidth > 0 && cornerWidth + nameWidth + NAME_TAG_GAP > box.width - pastLeft - pastRight;
|
|
419
|
+
const chipWidth = cornerWidth + (nameInChip ? nameWidth : 0);
|
|
420
|
+
const cornerFromLeft = chipWidth > box.width && box.x + box.width - pastRight - chipWidth < 0;
|
|
421
|
+
const cornerStyle = cornerFromLeft ? { left: `${pastLeft}px`, right: 'auto' } : pastRight ? { right: `${pastRight}px` } : undefined;
|
|
422
|
+
return (
|
|
423
|
+
<div className={`canvas-view__outline canvas-view__outline--${kind}`}
|
|
424
|
+
data-test-id={`inspector-${kind === 'pick-covered' ? 'pick' : kind}-box`}
|
|
425
|
+
data-outline={kind}
|
|
426
|
+
data-outline-node={box.id}
|
|
427
|
+
style={{ transform: `translate(${box.x}px, ${box.y}px)`, width: `${box.width}px`, height: `${box.height}px` }}>
|
|
428
|
+
{label && !nameInChip ? <span ref={nameRef}
|
|
429
|
+
className="canvas-view__tag"
|
|
430
|
+
data-test-id="inspector-name-tip"
|
|
431
|
+
style={pastLeft ? { left: `${pastLeft - 2}px` } : undefined}>{label}</span> : null}
|
|
432
|
+
{/* The other corner answers the other question a selection raises: how big is this thing —
|
|
433
|
+
and carries the one action that acts on the whole block. The two are one unbroken chip:
|
|
434
|
+
a red button floating on its own would read as part of the page under the overlay. */}
|
|
435
|
+
{size ? (
|
|
436
|
+
<div ref={cornerRef}
|
|
437
|
+
className="canvas-view__corner"
|
|
438
|
+
data-outline-corner={box.id}
|
|
439
|
+
style={cornerStyle}>
|
|
440
|
+
{nameInChip ? <span ref={chipNameRef}
|
|
441
|
+
className="canvas-view__tag canvas-view__tag--name"
|
|
442
|
+
data-test-id="inspector-name-tip">{label}</span> : null}
|
|
443
|
+
{ghost?.place === 'top' ? ghost.node : null}
|
|
444
|
+
{/* the settings wait behind this: open, they would cover the very page one is looking at
|
|
445
|
+
to decide where the next block goes. Left of the size — the delete button stays at
|
|
446
|
+
the far end, alone, where a click meant for the settings cannot land on it */}
|
|
447
|
+
{onSettings ? (
|
|
448
|
+
<button type="button"
|
|
449
|
+
className={`canvas-view__tag-action canvas-view__tag-action--settings${settingsOpen ? ' canvas-view__tag-action--on' : ''}${settingsLoading ? ' canvas-view__tag-action--loading' : ''}`}
|
|
450
|
+
data-test-id="inspector-settings-button"
|
|
451
|
+
data-action="node-settings"
|
|
452
|
+
data-loading={settingsLoading || undefined}
|
|
453
|
+
data-tooltip={settingsLoading ? 'Loading the block’s settings…' : settingsOpen ? 'Hide the block’s settings' : 'The block’s settings'}
|
|
454
|
+
aria-label={settingsOpen ? 'Hide the block’s settings' : 'The block’s settings'}
|
|
455
|
+
aria-pressed={settingsOpen ?? false}
|
|
456
|
+
onClick={onSettings}>
|
|
457
|
+
<svg viewBox="0 0 16 16"
|
|
458
|
+
aria-hidden="true">
|
|
459
|
+
<path d="M2.5 4.5h6M11.5 4.5h2M2.5 11.5h2M7.5 11.5h6"
|
|
460
|
+
fill="none"
|
|
461
|
+
stroke="currentColor"
|
|
462
|
+
strokeWidth="1.4"
|
|
463
|
+
strokeLinecap="round" />
|
|
464
|
+
<circle cx="10"
|
|
465
|
+
cy="4.5"
|
|
466
|
+
r="1.6"
|
|
467
|
+
fill="none"
|
|
468
|
+
stroke="currentColor"
|
|
469
|
+
strokeWidth="1.4" />
|
|
470
|
+
<circle cx="6"
|
|
471
|
+
cy="11.5"
|
|
472
|
+
r="1.6"
|
|
473
|
+
fill="none"
|
|
474
|
+
stroke="currentColor"
|
|
475
|
+
strokeWidth="1.4" />
|
|
476
|
+
</svg>
|
|
477
|
+
</button>
|
|
478
|
+
) : null}
|
|
479
|
+
<span className="canvas-view__tag canvas-view__tag--size"
|
|
480
|
+
data-test-id="inspector-size-tip"
|
|
481
|
+
data-outline-size>{Math.round(box.width)}×{Math.round(box.height)}</span>
|
|
482
|
+
{onRemove ? (
|
|
483
|
+
<button type="button"
|
|
484
|
+
className="canvas-view__tag-action"
|
|
485
|
+
data-test-id="inspector-remove-button"
|
|
486
|
+
data-action="remove-node"
|
|
487
|
+
data-tooltip="Delete this block"
|
|
488
|
+
aria-label="Delete this block"
|
|
489
|
+
onClick={onRemove}>
|
|
490
|
+
<svg viewBox="0 0 16 16"
|
|
491
|
+
aria-hidden="true">
|
|
492
|
+
<path d="M3.4 4.6h9.2M6.4 4.6V3.2h3.2v1.4M4.9 4.6l.6 8.2h5l.6-8.2"
|
|
493
|
+
fill="none"
|
|
494
|
+
stroke="currentColor"
|
|
495
|
+
strokeWidth="1.4" />
|
|
496
|
+
</svg>
|
|
497
|
+
</button>
|
|
498
|
+
) : null}
|
|
499
|
+
</div>
|
|
500
|
+
) : null}
|
|
501
|
+
{ghost?.place === 'bottom' ? (
|
|
502
|
+
<div className="canvas-view__corner canvas-view__corner--bottom"
|
|
503
|
+
style={cornerStyle}>{ghost.node}</div>
|
|
504
|
+
) : null}
|
|
505
|
+
{ghost?.place === 'left' || ghost?.place === 'right' ? (
|
|
506
|
+
<div className={`canvas-view__edge canvas-view__edge--${ghost.place}`}>{ghost.node}</div>
|
|
507
|
+
) : null}
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** a container with no width or height encloses nothing worth outlining — the "+" speaks for itself */
|
|
513
|
+
const drawableArea = (slot: ISlotBox) => (slot.area && slot.area.width > 0 && slot.area.height > 0 ? slot.area : null);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A place of the selected block at rest: every slot it opens, outlined quietly. This is the map of
|
|
517
|
+
* where things can go. It disappears the moment one place is hovered or picked — two greens on
|
|
518
|
+
* screen, one still and one travelling, collided both with each other and with the live frame.
|
|
519
|
+
*/
|
|
520
|
+
function SlotArea({ slot, area }: { slot: ISlotBox; area: NonNullable<ISlotBox['area']> }) {
|
|
521
|
+
return (
|
|
522
|
+
<div className="canvas-view__slot-area canvas-view__slot-area--resting"
|
|
523
|
+
data-slot-area={`${slot.ownerId}:${slot.slot}`}
|
|
524
|
+
style={{
|
|
525
|
+
transform: `translate(${area.x}px, ${area.y}px)`,
|
|
526
|
+
width: `${area.width}px`,
|
|
527
|
+
height: `${area.height}px`,
|
|
528
|
+
}} />
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
interface IRect {
|
|
533
|
+
readonly x: number;
|
|
534
|
+
readonly y: number;
|
|
535
|
+
readonly width: number;
|
|
536
|
+
readonly height: number;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The place a block is about to land in, marked: a container, or a band at a block's edge. Named
|
|
541
|
+
* in both corners the way a selected block is — what it is on the left, how big it is on the
|
|
542
|
+
* right. The travelling dashes make it the one thing moving on the overlay.
|
|
543
|
+
*/
|
|
544
|
+
function TargetFrame({ rect, label, area }: { rect: IRect | null; label: string; area: string }) {
|
|
545
|
+
const style = rect
|
|
546
|
+
? { transform: `translate(${rect.x}px, ${rect.y}px)`, width: `${rect.width}px`, height: `${rect.height}px` }
|
|
547
|
+
: { inset: 0 }; // nothing measurable to point at — the frame is the answer
|
|
548
|
+
return (
|
|
549
|
+
<div className="canvas-view__slot-area canvas-view__slot-area--target"
|
|
550
|
+
data-test-id="inspector-child-box"
|
|
551
|
+
data-slot-area={area}
|
|
552
|
+
data-slot-target
|
|
553
|
+
style={style}>
|
|
554
|
+
<span className="canvas-view__tag canvas-view__tag--slot">{label}</span>
|
|
555
|
+
{rect ? (
|
|
556
|
+
<span className="canvas-view__tag canvas-view__tag--slot canvas-view__tag--size"
|
|
557
|
+
data-slot-size>{Math.round(rect.width)}×{Math.round(rect.height)}</span>
|
|
558
|
+
) : null}
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* What a place lights up while it is pointed at: the CONTAINER the new block goes into — which
|
|
565
|
+
* block it joins is the one thing the ghost itself cannot show. The page root is no container at
|
|
566
|
+
* all (not a rendered slot): a place of the page itself lights nothing, its ghost is the answer.
|
|
567
|
+
*/
|
|
568
|
+
type TAddTarget = { readonly of: 'container' } | { readonly of: 'page' };
|
|
569
|
+
|
|
570
|
+
/** one place a block may go: what it says, what it would do, and what it lights up */
|
|
571
|
+
interface IAddPoint {
|
|
572
|
+
readonly key: string;
|
|
573
|
+
readonly title: string;
|
|
574
|
+
/** the structural name (docs/code-conventions): add-child-after — into the block's own (empty)
|
|
575
|
+
* slot, or the page's first block; add-before/after — next to the block, among its siblings;
|
|
576
|
+
* add-child-before — the page's start */
|
|
577
|
+
readonly testId: 'inspector-add-child-before-button' | 'inspector-add-child-after-button'
|
|
578
|
+
| 'inspector-add-before-button' | 'inspector-add-after-button';
|
|
579
|
+
readonly at: IInsertionPoint;
|
|
580
|
+
readonly target: TAddTarget;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A place offered on screen as a GHOST: a green tab with the place's words. Next to the selected
|
|
586
|
+
* block it is GLUED to the block's frame — on top a segment of the corner strip beside the size,
|
|
587
|
+
* below a tab hanging from the bottom edge, left or right a tab on that edge (the neighbours stand
|
|
588
|
+
* side by side) — so frame, tags and ghost read as one piece. A "+" floating in a corner said
|
|
589
|
+
* nothing to authors; a tab on the edge it adds next to says where. Inside an empty slot of the
|
|
590
|
+
* block, or at the page's own ends, it is a tag of its own (`free`).
|
|
591
|
+
*/
|
|
592
|
+
type TGhostPlace = 'top' | 'bottom' | 'left' | 'right' | 'free';
|
|
593
|
+
|
|
594
|
+
interface IGhost {
|
|
595
|
+
readonly point: IAddPoint;
|
|
596
|
+
readonly place: TGhostPlace;
|
|
597
|
+
/** where a free ghost stands (its top-left corner) — the glued ones go wherever their frame is */
|
|
598
|
+
readonly at: { readonly x: number; readonly y: number } | null;
|
|
599
|
+
/** what it says on the tab — short: the frame it is glued to names the block */
|
|
600
|
+
readonly words: string;
|
|
601
|
+
/**
|
|
602
|
+
* Next to something — `start` or `end` of it: only the side the pointer is on is shown, both at
|
|
603
|
+
* once crowd the block they are about. `inside` an empty slot: always shown.
|
|
604
|
+
*/
|
|
605
|
+
readonly side: 'start' | 'end' | 'inside';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** a side tab is at most this tall — a "+" on the edge, not a second frame along it */
|
|
609
|
+
const SIDE_TAB_MAX_HEIGHT = 48;
|
|
610
|
+
/** the insert popover's width — the search and a block's name with its provider */
|
|
611
|
+
const INSERT_POPOVER_WIDTH = 320;
|
|
612
|
+
|
|
613
|
+
/** a ghost's tab: a "+" and its words; on a side edge, too narrow for words — the tooltip has them */
|
|
614
|
+
function GhostTab({ ghost, height, onPick, onHover }: {
|
|
615
|
+
ghost: IGhost;
|
|
616
|
+
/** a side tab's height — the block's, up to SIDE_TAB_MAX_HEIGHT */
|
|
617
|
+
height?: number;
|
|
618
|
+
onPick: (at: IInsertionPoint) => void;
|
|
619
|
+
onHover: (point: IAddPoint | null) => void;
|
|
620
|
+
}) {
|
|
621
|
+
const { point, place, at, words } = ghost;
|
|
622
|
+
const narrow = place === 'left' || place === 'right';
|
|
623
|
+
const style = place === 'free' && at
|
|
624
|
+
? { transform: `translate(${at.x}px, ${at.y}px)` }
|
|
625
|
+
: narrow && height !== undefined ? { height: `${height}px` } : undefined;
|
|
626
|
+
return (
|
|
627
|
+
<button type="button"
|
|
628
|
+
className={`canvas-view__ghost canvas-view__ghost--${place}`}
|
|
629
|
+
data-test-id={point.testId}
|
|
630
|
+
data-add-point={point.key}
|
|
631
|
+
data-add-side={ghost.side}
|
|
632
|
+
data-add-place={place}
|
|
633
|
+
data-tooltip={narrow ? words : undefined}
|
|
634
|
+
aria-label={point.title}
|
|
635
|
+
style={style}
|
|
636
|
+
onPointerEnter={() => onHover(point)}
|
|
637
|
+
onPointerLeave={() => onHover(null)}
|
|
638
|
+
onClick={() => onPick(point.at)}>
|
|
639
|
+
<span className="canvas-view__ghost-plus"
|
|
640
|
+
aria-hidden="true">+</span>
|
|
641
|
+
{narrow ? null : <span className="canvas-view__ghost-words">{words}</span>}
|
|
642
|
+
</button>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Where the picked block will LAND: the emptiness the agent made room with (LANDING_NODE_ID),
|
|
648
|
+
* framed in a colour of its own — green says which block it joins, violet says the very spot.
|
|
649
|
+
*/
|
|
650
|
+
function LandingFrame({ box }: { box: INodeBox }) {
|
|
651
|
+
return (
|
|
652
|
+
<div className="canvas-view__landing"
|
|
653
|
+
data-test-id="inspector-landing-box"
|
|
654
|
+
style={{ transform: `translate(${box.x}px, ${box.y}px)`, width: `${box.width}px`, height: `${box.height}px` }}>
|
|
655
|
+
<span className="canvas-view__tag canvas-view__tag--landing">the new block lands here</span>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The bar above the frame. It earns its place twice: it holds the actions a preview needs, and its
|
|
662
|
+
* height is the room the topmost outline's label needs — hanging off the very top of the frame,
|
|
663
|
+
* that label used to be cut off by the panel's edge.
|
|
664
|
+
*/
|
|
665
|
+
/** the tree column, drawn as a sidebar — the toggle the workbench's desk offers in this bar */
|
|
666
|
+
const TreeIcon = (
|
|
667
|
+
<svg viewBox="0 0 16 16"
|
|
668
|
+
aria-hidden="true">
|
|
669
|
+
<rect x="1.8"
|
|
670
|
+
y="2.8"
|
|
671
|
+
width="12.4"
|
|
672
|
+
height="10.4"
|
|
673
|
+
rx="1.6"
|
|
674
|
+
fill="none"
|
|
675
|
+
stroke="currentColor"
|
|
676
|
+
strokeWidth="1.5" />
|
|
677
|
+
<path d="M6 2.8v10.4"
|
|
678
|
+
fill="none"
|
|
679
|
+
stroke="currentColor"
|
|
680
|
+
strokeWidth="1.5" />
|
|
681
|
+
</svg>
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
/** the preview, opened on its own: an arrow leaving a box */
|
|
685
|
+
const OpenPreviewIcon = (
|
|
686
|
+
<svg viewBox="0 0 16 16"
|
|
687
|
+
aria-hidden="true">
|
|
688
|
+
<path d="M6.5 3.5H3.5v9h9V9.5M9 3.5h3.5V7M12.5 3.5L7.5 8.5"
|
|
689
|
+
fill="none"
|
|
690
|
+
stroke="currentColor"
|
|
691
|
+
strokeWidth="1.5"
|
|
692
|
+
strokeLinecap="round" />
|
|
693
|
+
</svg>
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const LinkIcon = (
|
|
697
|
+
<svg viewBox="0 0 16 16"
|
|
698
|
+
aria-hidden="true">
|
|
699
|
+
<path d="M6.8 9.2a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-1 1"
|
|
700
|
+
fill="none"
|
|
701
|
+
stroke="currentColor"
|
|
702
|
+
strokeWidth="1.5"
|
|
703
|
+
strokeLinecap="round" />
|
|
704
|
+
<path d="M9.2 6.8a3 3 0 0 0-4.2 0l-2 2a3 3 0 0 0 4.2 4.2l1-1"
|
|
705
|
+
fill="none"
|
|
706
|
+
stroke="currentColor"
|
|
707
|
+
strokeWidth="1.5"
|
|
708
|
+
strokeLinecap="round" />
|
|
709
|
+
</svg>
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
/** what the bar says about the version open: a draft (its name, whether its edits are on disk), or a published revision */
|
|
713
|
+
type TVersionBadge
|
|
714
|
+
= | { readonly kind: 'draft'; readonly name: string; readonly saving: boolean }
|
|
715
|
+
| { readonly kind: 'revision'; readonly rev: number };
|
|
716
|
+
|
|
717
|
+
/** a published revision's way to an edit: a fresh draft of it, opened beside it */
|
|
718
|
+
interface INewDraftControl {
|
|
719
|
+
readonly busy: boolean;
|
|
720
|
+
readonly onNewDraft: () => void;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** the blocks of the page with newer versions — the chip that opens the updates window (ADR-0061) */
|
|
724
|
+
interface IUpdatesControl {
|
|
725
|
+
readonly count: number;
|
|
726
|
+
readonly onOpen: () => void;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const PublishIcon = (
|
|
730
|
+
<svg viewBox="0 0 16 16"
|
|
731
|
+
aria-hidden="true">
|
|
732
|
+
<path d="M8 10.5V2.8M4.8 5.8L8 2.6l3.2 3.2M3 9.5v2.7c0 .7.5 1.2 1.2 1.2h7.6c.7 0 1.2-.5 1.2-1.2V9.5"
|
|
733
|
+
fill="none"
|
|
734
|
+
stroke="currentColor"
|
|
735
|
+
strokeWidth="1.5"
|
|
736
|
+
strokeLinecap="round"
|
|
737
|
+
strokeLinejoin="round" />
|
|
738
|
+
</svg>
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const NewDraftIcon = (
|
|
742
|
+
<svg viewBox="0 0 16 16"
|
|
743
|
+
aria-hidden="true">
|
|
744
|
+
<path d="M9.5 2.5H4.2c-.7 0-1.2.5-1.2 1.2v8.6c0 .7.5 1.2 1.2 1.2h7.6c.7 0 1.2-.5 1.2-1.2V6M8 6.5v5M5.5 9h5M12.8 1.6l1.6 1.6-3.9 3.9-2 .4.4-2z"
|
|
745
|
+
fill="none"
|
|
746
|
+
stroke="currentColor"
|
|
747
|
+
strokeWidth="1.3"
|
|
748
|
+
strokeLinecap="round"
|
|
749
|
+
strokeLinejoin="round" />
|
|
750
|
+
</svg>
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
/** the draft's way to the site: shown while a draft is open, quiet while it is being saved */
|
|
754
|
+
interface IPublishControl {
|
|
755
|
+
readonly busy: boolean;
|
|
756
|
+
/** unsaved edits are on their way to the store — publishing now would miss them */
|
|
757
|
+
readonly dirty: boolean;
|
|
758
|
+
readonly onPublish: () => void;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** a tool of the workbench as the bar shows it */
|
|
762
|
+
interface IToolFace {
|
|
763
|
+
readonly id: string;
|
|
764
|
+
readonly label: string;
|
|
765
|
+
readonly iconUrl?: string;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const ToolIcon = (
|
|
769
|
+
<svg viewBox="0 0 16 16"
|
|
770
|
+
aria-hidden="true">
|
|
771
|
+
<path d="M3 4.5h10M3 8h10M3 11.5h6"
|
|
772
|
+
fill="none"
|
|
773
|
+
stroke="currentColor"
|
|
774
|
+
strokeWidth="1.4"
|
|
775
|
+
strokeLinecap="round" />
|
|
776
|
+
</svg>
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
function ActionBar({ inspecting, chromes, openChrome, onChrome, tools, openTool, onTool, pageSettingsOpen, onPageSettings, version, newDraft, updates, canCut, canCopy, canPaste, canUndo, canRedo, previewHref, onShare, publish, onRestart, onFullscreen, onInspecting, onCut, onCopy, onPaste, onUndo, onRedo }: {
|
|
780
|
+
inspecting: boolean;
|
|
781
|
+
/** the standing controls the page's blocks bring — a button each, the open one's card in the corner */
|
|
782
|
+
chromes: readonly IChromeNode[];
|
|
783
|
+
openChrome: string | null;
|
|
784
|
+
onChrome: (id: string) => void;
|
|
785
|
+
/** the workbench's tools (ADR-0062) — a button each, the open one over the stage */
|
|
786
|
+
tools: readonly IToolFace[];
|
|
787
|
+
openTool: string | null;
|
|
788
|
+
onTool: (id: string) => void;
|
|
789
|
+
pageSettingsOpen: boolean;
|
|
790
|
+
/** toggles the page's own settings; null where there are none (a prefab) */
|
|
791
|
+
onPageSettings: (() => void) | null;
|
|
792
|
+
/** the version open — a revision puts the bar in the read-only mood, the badge says WHY nothing can be edited */
|
|
793
|
+
version: TVersionBadge | null;
|
|
794
|
+
/** a revision's way to a draft of it; null where there is none (a draft open, a frozen page) */
|
|
795
|
+
newDraft: INewDraftControl | null;
|
|
796
|
+
/** the page's blocks with newer versions; null where there are none, or nothing can change */
|
|
797
|
+
updates: IUpdatesControl | null;
|
|
798
|
+
canCut: boolean;
|
|
799
|
+
canCopy: boolean;
|
|
800
|
+
canPaste: boolean;
|
|
801
|
+
canUndo: boolean;
|
|
802
|
+
canRedo: boolean;
|
|
803
|
+
/** the preview's own address — the same page without the editor, for a new tab and for sharing */
|
|
804
|
+
previewHref: string;
|
|
805
|
+
onShare: () => void;
|
|
806
|
+
/** null while a revision is inspected — nothing to publish */
|
|
807
|
+
publish: IPublishControl | null;
|
|
808
|
+
onRestart: () => void;
|
|
809
|
+
onFullscreen: () => void;
|
|
810
|
+
onInspecting: (on: boolean) => void;
|
|
811
|
+
onCut: () => void;
|
|
812
|
+
onCopy: () => void;
|
|
813
|
+
onPaste: () => void;
|
|
814
|
+
onUndo: () => void;
|
|
815
|
+
onRedo: () => void;
|
|
816
|
+
}) {
|
|
817
|
+
// hosted by the workbench's desk? its tree column is a click away from here — the bar is where
|
|
818
|
+
// the hands are. Optional, like every host cooperation
|
|
819
|
+
const workspace = useDiOptional(WORKSPACE_TOKEN);
|
|
820
|
+
const desk = useSyncExternalStore(workspace?.subscribe ?? noSubscription, workspace?.snapshot ?? noState, noState);
|
|
821
|
+
return (
|
|
822
|
+
<div className="canvas-view__bar">
|
|
823
|
+
{workspace && desk ? (
|
|
824
|
+
<>
|
|
825
|
+
<button type="button"
|
|
826
|
+
className={`canvas-view__action${desk.treeShown ? ' canvas-view__action--on' : ''}`}
|
|
827
|
+
data-test-id="canvas-tree-toggle"
|
|
828
|
+
data-action="tree"
|
|
829
|
+
data-tooltip={desk.treeShown ? 'Hide the tree' : 'Show the tree — the outline of the page beside the canvas'}
|
|
830
|
+
aria-label={desk.treeShown ? 'Hide the tree' : 'Show the tree'}
|
|
831
|
+
aria-pressed={desk.treeShown}
|
|
832
|
+
onClick={() => workspace.setTreeShown(!desk.treeShown)}>
|
|
833
|
+
{TreeIcon}
|
|
834
|
+
</button>
|
|
835
|
+
<span className="canvas-view__divider"
|
|
836
|
+
aria-hidden="true" />
|
|
837
|
+
</>
|
|
838
|
+
) : null}
|
|
839
|
+
<button type="button"
|
|
840
|
+
className="canvas-view__action"
|
|
841
|
+
data-test-id="canvas-restart-button"
|
|
842
|
+
data-action="restart"
|
|
843
|
+
data-tooltip="Restart the preview — a fresh page, nothing remembered"
|
|
844
|
+
aria-label="Restart the preview"
|
|
845
|
+
onClick={onRestart}>
|
|
846
|
+
<svg viewBox="0 0 16 16"
|
|
847
|
+
aria-hidden="true">
|
|
848
|
+
<path d="M13.2 8a5.2 5.2 0 1 1-1.6-3.7"
|
|
849
|
+
fill="none"
|
|
850
|
+
stroke="currentColor"
|
|
851
|
+
strokeWidth="1.6" />
|
|
852
|
+
<path d="M12.4 1.6v3.4H9"
|
|
853
|
+
fill="none"
|
|
854
|
+
stroke="currentColor"
|
|
855
|
+
strokeWidth="1.6" />
|
|
856
|
+
</svg>
|
|
857
|
+
</button>
|
|
858
|
+
<button type="button"
|
|
859
|
+
className="canvas-view__action canvas-view__action--play"
|
|
860
|
+
data-test-id="canvas-fullscreen-button"
|
|
861
|
+
data-action="fullscreen"
|
|
862
|
+
data-tooltip="Full screen — the page over everything else"
|
|
863
|
+
aria-label="Full screen"
|
|
864
|
+
onClick={onFullscreen}>
|
|
865
|
+
<svg viewBox="0 0 16 16"
|
|
866
|
+
aria-hidden="true">
|
|
867
|
+
<path d="M4.5 3l8 5-8 5z"
|
|
868
|
+
fill="currentColor" />
|
|
869
|
+
</svg>
|
|
870
|
+
</button>
|
|
871
|
+
<button type="button"
|
|
872
|
+
className={`canvas-view__action canvas-view__action--picker${inspecting ? '' : ' canvas-view__action--off'}`}
|
|
873
|
+
data-test-id="canvas-inspect-toggle"
|
|
874
|
+
data-action="inspect"
|
|
875
|
+
data-inspecting={inspecting || undefined}
|
|
876
|
+
data-tooltip={inspecting
|
|
877
|
+
? 'Inspector on — clicks select blocks. Turn it off to use the page itself'
|
|
878
|
+
: 'Inspector off — the page behaves as it will for a visitor'}
|
|
879
|
+
aria-label={inspecting ? 'Turn the inspector off' : 'Turn the inspector on'}
|
|
880
|
+
aria-pressed={inspecting}
|
|
881
|
+
onClick={() => onInspecting(!inspecting)}>
|
|
882
|
+
<svg viewBox="0 0 16 16"
|
|
883
|
+
aria-hidden="true">
|
|
884
|
+
<rect x="1.6"
|
|
885
|
+
y="1.6"
|
|
886
|
+
width="8.6"
|
|
887
|
+
height="8.6"
|
|
888
|
+
rx="1.2"
|
|
889
|
+
fill="none"
|
|
890
|
+
stroke="currentColor"
|
|
891
|
+
strokeWidth="1.5" />
|
|
892
|
+
<path d="M8.2 8.2l6.2 2.4-2.6.8-.8 2.6z"
|
|
893
|
+
fill="currentColor" />
|
|
894
|
+
</svg>
|
|
895
|
+
</button>
|
|
896
|
+
{onPageSettings || chromes.length || tools.length ? <span className="canvas-view__divider"
|
|
897
|
+
aria-hidden="true" /> : null}
|
|
898
|
+
{onPageSettings ? (
|
|
899
|
+
<button type="button"
|
|
900
|
+
className={`canvas-view__action${pageSettingsOpen ? ' canvas-view__action--on' : ''}`}
|
|
901
|
+
data-test-id="canvas-page-settings"
|
|
902
|
+
data-action="page-settings"
|
|
903
|
+
data-tooltip={pageSettingsOpen ? 'Close the page’s settings' : 'The page’s own settings: its title, description, cover, server rendering'}
|
|
904
|
+
aria-label="Page settings"
|
|
905
|
+
aria-expanded={pageSettingsOpen}
|
|
906
|
+
onClick={onPageSettings}>
|
|
907
|
+
{PageSettingsIcon}
|
|
908
|
+
</button>
|
|
909
|
+
) : null}
|
|
910
|
+
{chromes.map((node) => {
|
|
911
|
+
const face = chromeFaceOf(node);
|
|
912
|
+
const open = openChrome === node.id;
|
|
913
|
+
return (
|
|
914
|
+
<button key={node.id}
|
|
915
|
+
type="button"
|
|
916
|
+
className={`canvas-view__action${open ? ' canvas-view__action--on' : ''}`}
|
|
917
|
+
data-test-id="canvas-chrome-toggle"
|
|
918
|
+
data-action="chrome"
|
|
919
|
+
data-chrome={node.id}
|
|
920
|
+
data-tooltip={open ? `Hide: ${face.label.toLowerCase()}` : face.label}
|
|
921
|
+
aria-label={face.label}
|
|
922
|
+
aria-expanded={open}
|
|
923
|
+
onClick={() => onChrome(node.id)}>
|
|
924
|
+
{face.icon}
|
|
925
|
+
</button>
|
|
926
|
+
);
|
|
927
|
+
})}
|
|
928
|
+
{tools.map((tool) => {
|
|
929
|
+
const open = openTool === tool.id;
|
|
930
|
+
return (
|
|
931
|
+
<button key={tool.id}
|
|
932
|
+
type="button"
|
|
933
|
+
className={`canvas-view__action${open ? ' canvas-view__action--on' : ''}`}
|
|
934
|
+
data-test-id="canvas-tool-toggle"
|
|
935
|
+
data-action="tool"
|
|
936
|
+
data-tool={tool.id}
|
|
937
|
+
data-tooltip={open ? `Hide: ${tool.label.toLowerCase()}` : tool.label}
|
|
938
|
+
aria-label={tool.label}
|
|
939
|
+
aria-expanded={open}
|
|
940
|
+
onClick={() => onTool(tool.id)}>
|
|
941
|
+
{tool.iconUrl ? <img className="canvas-view__tool-icon"
|
|
942
|
+
src={tool.iconUrl}
|
|
943
|
+
alt="" /> : ToolIcon}
|
|
944
|
+
</button>
|
|
945
|
+
);
|
|
946
|
+
})}
|
|
947
|
+
{version?.kind === 'revision' ? (
|
|
948
|
+
<span className="canvas-view__readonly"
|
|
949
|
+
data-test-id="canvas-readonly-badge"
|
|
950
|
+
data-tooltip="A published revision is immutable — make a draft of it to change it">
|
|
951
|
+
{`published r${version.rev} — read-only`}
|
|
952
|
+
</span>
|
|
953
|
+
) : null}
|
|
954
|
+
{/* …and a draft says which one it is, and whether its edits are on disk yet */}
|
|
955
|
+
{version?.kind === 'draft' ? (
|
|
956
|
+
<span className={`canvas-view__readonly canvas-view__readonly--draft${version.saving ? ' canvas-view__readonly--saving' : ''}`}
|
|
957
|
+
data-test-id="canvas-draft-badge"
|
|
958
|
+
data-saving={version.saving || undefined}
|
|
959
|
+
data-tooltip={version.saving
|
|
960
|
+
? 'Saving your edits — a draft saves by itself'
|
|
961
|
+
: 'A draft: your edits are saved by themselves; Publish makes it the page readers get'}>
|
|
962
|
+
<span className="canvas-view__save-dot"
|
|
963
|
+
aria-hidden="true" />
|
|
964
|
+
{`draft ${version.name} — ${version.saving ? 'saving…' : 'saved'}`}
|
|
965
|
+
</span>
|
|
966
|
+
) : null}
|
|
967
|
+
{newDraft ? (
|
|
968
|
+
<button type="button"
|
|
969
|
+
className="canvas-view__new-draft"
|
|
970
|
+
data-test-id="canvas-new-draft-button"
|
|
971
|
+
data-action="new-draft"
|
|
972
|
+
disabled={newDraft.busy}
|
|
973
|
+
data-tooltip="Make a draft of this revision to change it — it opens beside this one"
|
|
974
|
+
onClick={newDraft.onNewDraft}>
|
|
975
|
+
{NewDraftIcon}
|
|
976
|
+
{newDraft.busy ? 'Making a draft…' : 'New draft'}
|
|
977
|
+
</button>
|
|
978
|
+
) : null}
|
|
979
|
+
{updates ? (
|
|
980
|
+
<button type="button"
|
|
981
|
+
className="canvas-view__updates"
|
|
982
|
+
data-test-id="canvas-updates-button"
|
|
983
|
+
data-action="updates"
|
|
984
|
+
data-count={updates.count}
|
|
985
|
+
data-tooltip="Some blocks of this page have newer versions — see what changed and update them"
|
|
986
|
+
onClick={updates.onOpen}>
|
|
987
|
+
{`↑ Updates · ${updates.count}`}
|
|
988
|
+
</button>
|
|
989
|
+
) : null}
|
|
990
|
+
{publish ? (
|
|
991
|
+
<button type="button"
|
|
992
|
+
className="canvas-view__publish"
|
|
993
|
+
data-test-id="canvas-publish-button"
|
|
994
|
+
data-action="publish"
|
|
995
|
+
disabled={publish.busy || publish.dirty}
|
|
996
|
+
data-tooltip={publish.dirty
|
|
997
|
+
? 'Wait a moment — the draft is being saved'
|
|
998
|
+
: 'Publish this draft — it becomes the live revision readers get'}
|
|
999
|
+
onClick={publish.onPublish}>
|
|
1000
|
+
{PublishIcon}
|
|
1001
|
+
{publish.busy ? 'Publishing…' : 'Publish'}
|
|
1002
|
+
</button>
|
|
1003
|
+
) : null}
|
|
1004
|
+
{/* the right group is about the DRAFT, not about the frame the left-side actions drive:
|
|
1005
|
+
cut/copy/paste of the selection, then undo/redo. Disabled IS the empty state: the
|
|
1006
|
+
colour (blue can, gray cannot) is the whole status UI */}
|
|
1007
|
+
<button type="button"
|
|
1008
|
+
className="canvas-view__action canvas-view__action--history canvas-view__action--right"
|
|
1009
|
+
data-test-id="canvas-cut-button"
|
|
1010
|
+
data-action="cut"
|
|
1011
|
+
data-tooltip="Cut the selection (Ctrl+X)"
|
|
1012
|
+
aria-label="Cut the selection"
|
|
1013
|
+
disabled={!canCut}
|
|
1014
|
+
onClick={onCut}>
|
|
1015
|
+
<svg viewBox="0 0 16 16"
|
|
1016
|
+
aria-hidden="true">
|
|
1017
|
+
<circle cx="4"
|
|
1018
|
+
cy="4.2"
|
|
1019
|
+
r="2"
|
|
1020
|
+
fill="none"
|
|
1021
|
+
stroke="currentColor"
|
|
1022
|
+
strokeWidth="1.4" />
|
|
1023
|
+
<circle cx="4"
|
|
1024
|
+
cy="11.8"
|
|
1025
|
+
r="2"
|
|
1026
|
+
fill="none"
|
|
1027
|
+
stroke="currentColor"
|
|
1028
|
+
strokeWidth="1.4" />
|
|
1029
|
+
<path d="M13.4 2.6L5.6 10.7M5.6 5.3l3.2 3.3M9.9 9.6l3.5 3.8"
|
|
1030
|
+
fill="none"
|
|
1031
|
+
stroke="currentColor"
|
|
1032
|
+
strokeWidth="1.4" />
|
|
1033
|
+
</svg>
|
|
1034
|
+
</button>
|
|
1035
|
+
<button type="button"
|
|
1036
|
+
className="canvas-view__action canvas-view__action--history"
|
|
1037
|
+
data-test-id="canvas-copy-button"
|
|
1038
|
+
data-action="copy"
|
|
1039
|
+
data-tooltip="Copy the selection (Ctrl+C)"
|
|
1040
|
+
aria-label="Copy the selection"
|
|
1041
|
+
disabled={!canCopy}
|
|
1042
|
+
onClick={onCopy}>
|
|
1043
|
+
<svg viewBox="0 0 16 16"
|
|
1044
|
+
aria-hidden="true">
|
|
1045
|
+
<rect x="5.6"
|
|
1046
|
+
y="5.6"
|
|
1047
|
+
width="7.6"
|
|
1048
|
+
height="7.6"
|
|
1049
|
+
rx="1.4"
|
|
1050
|
+
fill="none"
|
|
1051
|
+
stroke="currentColor"
|
|
1052
|
+
strokeWidth="1.6" />
|
|
1053
|
+
<path d="M3 10.4V4.4A1.6 1.6 0 0 1 4.6 2.8h6"
|
|
1054
|
+
fill="none"
|
|
1055
|
+
stroke="currentColor"
|
|
1056
|
+
strokeWidth="1.6" />
|
|
1057
|
+
</svg>
|
|
1058
|
+
</button>
|
|
1059
|
+
<button type="button"
|
|
1060
|
+
className="canvas-view__action canvas-view__action--history"
|
|
1061
|
+
data-test-id="canvas-paste-button"
|
|
1062
|
+
data-action="paste"
|
|
1063
|
+
data-tooltip="Paste blocks (Ctrl+V)"
|
|
1064
|
+
aria-label="Paste blocks"
|
|
1065
|
+
disabled={!canPaste}
|
|
1066
|
+
onClick={onPaste}>
|
|
1067
|
+
<svg viewBox="0 0 16 16"
|
|
1068
|
+
aria-hidden="true">
|
|
1069
|
+
<path d="M10.4 2.9h1.2A1.4 1.4 0 0 1 13 4.3v9A1.4 1.4 0 0 1 11.6 14.7H4.4A1.4 1.4 0 0 1 3 13.3v-9A1.4 1.4 0 0 1 4.4 2.9h1.2"
|
|
1070
|
+
fill="none"
|
|
1071
|
+
stroke="currentColor"
|
|
1072
|
+
strokeWidth="1.6" />
|
|
1073
|
+
<rect x="5.5"
|
|
1074
|
+
y="1.4"
|
|
1075
|
+
width="5"
|
|
1076
|
+
height="2.9"
|
|
1077
|
+
rx="0.7"
|
|
1078
|
+
fill="none"
|
|
1079
|
+
stroke="currentColor"
|
|
1080
|
+
strokeWidth="1.6" />
|
|
1081
|
+
</svg>
|
|
1082
|
+
</button>
|
|
1083
|
+
<button type="button"
|
|
1084
|
+
className="canvas-view__action canvas-view__action--history"
|
|
1085
|
+
data-test-id="canvas-undo-button"
|
|
1086
|
+
data-action="undo"
|
|
1087
|
+
data-tooltip="Undo (Ctrl+Z)"
|
|
1088
|
+
aria-label="Undo"
|
|
1089
|
+
disabled={!canUndo}
|
|
1090
|
+
onClick={onUndo}>
|
|
1091
|
+
<svg viewBox="0 0 16 16"
|
|
1092
|
+
aria-hidden="true">
|
|
1093
|
+
<path d="M2 4.7v4h4"
|
|
1094
|
+
fill="none"
|
|
1095
|
+
stroke="currentColor"
|
|
1096
|
+
strokeWidth="1.6" />
|
|
1097
|
+
<path d="M14 11.3a6 6 0 0 0-10-4.4L2 8.7"
|
|
1098
|
+
fill="none"
|
|
1099
|
+
stroke="currentColor"
|
|
1100
|
+
strokeWidth="1.6" />
|
|
1101
|
+
</svg>
|
|
1102
|
+
</button>
|
|
1103
|
+
<button type="button"
|
|
1104
|
+
className="canvas-view__action canvas-view__action--history"
|
|
1105
|
+
data-test-id="canvas-redo-button"
|
|
1106
|
+
data-action="redo"
|
|
1107
|
+
data-tooltip="Redo (Ctrl+Shift+Z)"
|
|
1108
|
+
aria-label="Redo"
|
|
1109
|
+
disabled={!canRedo}
|
|
1110
|
+
onClick={onRedo}>
|
|
1111
|
+
<svg viewBox="0 0 16 16"
|
|
1112
|
+
aria-hidden="true">
|
|
1113
|
+
<path d="M14 4.7v4h-4"
|
|
1114
|
+
fill="none"
|
|
1115
|
+
stroke="currentColor"
|
|
1116
|
+
strokeWidth="1.6" />
|
|
1117
|
+
<path d="M2 11.3a6 6 0 0 1 10-4.4L14 8.7"
|
|
1118
|
+
fill="none"
|
|
1119
|
+
stroke="currentColor"
|
|
1120
|
+
strokeWidth="1.6" />
|
|
1121
|
+
</svg>
|
|
1122
|
+
</button>
|
|
1123
|
+
{/* the preview on its own: in a new tab, or as a link to hand to someone who may edit */}
|
|
1124
|
+
<span className="canvas-view__divider"
|
|
1125
|
+
aria-hidden="true" />
|
|
1126
|
+
<a className="canvas-view__action canvas-view__action--history"
|
|
1127
|
+
data-test-id="canvas-open-preview"
|
|
1128
|
+
data-action="open-preview"
|
|
1129
|
+
data-tooltip="Open this preview in a new tab — the page as it is here, without the editor"
|
|
1130
|
+
aria-label="Open the preview in a new tab"
|
|
1131
|
+
href={previewHref}
|
|
1132
|
+
target="_blank"
|
|
1133
|
+
rel="noreferrer">
|
|
1134
|
+
{OpenPreviewIcon}
|
|
1135
|
+
</a>
|
|
1136
|
+
<button type="button"
|
|
1137
|
+
className="canvas-view__action canvas-view__action--history"
|
|
1138
|
+
data-test-id="canvas-share-preview"
|
|
1139
|
+
data-action="share-preview"
|
|
1140
|
+
data-tooltip="Copy a link to this preview — anyone who may edit this site can open it"
|
|
1141
|
+
aria-label="Copy a link to the preview"
|
|
1142
|
+
onClick={onShare}>
|
|
1143
|
+
{LinkIcon}
|
|
1144
|
+
</button>
|
|
1145
|
+
</div>
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// a function DECLARATION, not an arrow in the module literal: the component carries its own name
|
|
1150
|
+
/**
|
|
1151
|
+
* The width of a settings card holding a wide field (a paragraph's text): the field gets at least
|
|
1152
|
+
* half the screen — in stage pixels (an emulated viewport scales the stage) — plus the card's own
|
|
1153
|
+
* padding and the reference gutter beside its fields; never wider than the stage.
|
|
1154
|
+
*/
|
|
1155
|
+
function wideFieldWidth(stage: HTMLElement | null, stageWidth: number): number {
|
|
1156
|
+
const view = stage?.ownerDocument.defaultView;
|
|
1157
|
+
const shown = stage?.getBoundingClientRect().width ?? 0;
|
|
1158
|
+
const scale = shown > 0 && stageWidth > 0 ? shown / stageWidth : 1;
|
|
1159
|
+
const half = (view?.innerWidth ?? stageWidth * 2) / 2 / scale;
|
|
1160
|
+
return Math.min(stageWidth - 16, Math.round(half + 64));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function Canvas() {
|
|
1164
|
+
// the canvas's OWN session, scoped to its dock tab — the rest of the workbench follows the
|
|
1165
|
+
// ACTIVE one through the hub's facade
|
|
1166
|
+
const { session, state, markInteractive, markHovered, markLeft } = useCanvasEditingSession();
|
|
1167
|
+
const workbenchDi = useDi(DI_CONTAINER_TOKEN);
|
|
1168
|
+
const notices = noticesOf(workbenchDi);
|
|
1169
|
+
// a provider's chrome in this canvas reads and sets the author's views, reads the project's
|
|
1170
|
+
// config and its own scope (what of it is untranslated) — all exposed by the workbench, fed here
|
|
1171
|
+
// too so the canvas does not need the panel or an opened settings popover
|
|
1172
|
+
ensureEditorViews(workbenchDi, session);
|
|
1173
|
+
ensureSettingsDraft(workbenchDi, session);
|
|
1174
|
+
useFeedProjectConfig();
|
|
1175
|
+
// a provider taking new blocks under its wing links what arrives in this canvas's document
|
|
1176
|
+
useEffect(() => watchArrivals(session, ensureRefsEditing(workbenchDi)), [session, workbenchDi]);
|
|
1177
|
+
const preview = useIsPreviewMode();
|
|
1178
|
+
// the project's prefabs: a prefab's own block, picked to be seen, is found in its live tree
|
|
1179
|
+
const project = useProjectPrefabs(!preview);
|
|
1180
|
+
useFeedPrefabTrees(workbenchDi, project);
|
|
1181
|
+
// hosted in a dock? name OUR tab after the open document and report the draft's identity, so
|
|
1182
|
+
// "edit this draft" can find an already-open tab. Entirely optional: no dock — nothing to name
|
|
1183
|
+
const dockTab = useDiOptional(DOCK_TAB_TOKEN);
|
|
1184
|
+
useEffect(() => {
|
|
1185
|
+
if (!dockTab) return;
|
|
1186
|
+
const revSuffix = state.revision != null ? ` · r${state.revision}` : '';
|
|
1187
|
+
const named = state.kind === 'prefabs' ? `prefab ${state.pageName}` : state.pageName;
|
|
1188
|
+
dockTab.rename(state.pageName ? `Canvas · ${named}${revSuffix}` : 'Canvas');
|
|
1189
|
+
dockTab.setMeta({ editingPage: state.pageName, editingDraftId: state.draftId, editingRevision: state.revision });
|
|
1190
|
+
}, [dockTab, state.kind, state.pageName, state.draftId, state.revision]);
|
|
1191
|
+
// a restart throws the frame away and builds a new one: the surest way to a preview that
|
|
1192
|
+
// remembers nothing of what was clicked in it
|
|
1193
|
+
const [restarts, setRestarts] = useState(0);
|
|
1194
|
+
// …and a rebuild does the same for the one reason the editor cannot talk its way out of: a
|
|
1195
|
+
// block whose module the running realm never loaded (see the effects below)
|
|
1196
|
+
const [rebuilds, setRebuilds] = useState(0);
|
|
1197
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
1198
|
+
// the place under the pointer, so its container (or the band at a block's edge) can light up
|
|
1199
|
+
// before it is picked — hovering a "+" should answer "where" before the click, not after
|
|
1200
|
+
const [hoveredAdd, setHoveredAdd] = useState<IAddPoint | null>(null);
|
|
1201
|
+
// a pick already IS the selection — revealing it would yank the page under the pointer
|
|
1202
|
+
const pickedRef = useRef<string | null>(null);
|
|
1203
|
+
const onPick = useCallback((nodeId: string | null, ctrl: boolean, part: string | null) => {
|
|
1204
|
+
// pointer events live INSIDE the iframe and never reach the wrapper's capture handler — the
|
|
1205
|
+
// agent's report is where this canvas learns it was touched, so activation happens here
|
|
1206
|
+
markInteractive();
|
|
1207
|
+
const doc = session.snapshot().doc;
|
|
1208
|
+
if (!doc) return;
|
|
1209
|
+
// a place is waiting for a block: whatever was clicked, the click means "never mind"
|
|
1210
|
+
if (session.snapshot().insertionPoint) {
|
|
1211
|
+
session.setInsertionPoint(null);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (ctrl) {
|
|
1215
|
+
// the Ctrl mode: a click toggles the branch whole. On no block it toggles nothing — a
|
|
1216
|
+
// stray Ctrl-click must not throw away an accumulated selection
|
|
1217
|
+
if (nodeId && findNodeDoc(doc, nodeId)) session.toggleMultiSelect(nodeId);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
// a click that landed on no block — or on the page itself, its root — means "never mind": the
|
|
1221
|
+
// page background is how one steps back out of a selection. Any block is picked at once, open
|
|
1222
|
+
// settings or not: they wait behind a button now and follow the selection (Canvas below), so
|
|
1223
|
+
// there is no card a click away would have to close first
|
|
1224
|
+
if (!nodeId || nodeId === doc.id) {
|
|
1225
|
+
pickedRef.current = null;
|
|
1226
|
+
session.selectNode(null);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// ids the draft no longer knows (an edit landed after the frame rendered) are dropped
|
|
1230
|
+
if (!findNodeDoc(doc, nodeId)) return;
|
|
1231
|
+
// a PREFAB's own block is picked as itself — to be seen, read-only (prefabParts.ts); its prefab
|
|
1232
|
+
// node is the Ctrl mode's unit, and a click on the tree's prefab row
|
|
1233
|
+
const picked = part ?? nodeId;
|
|
1234
|
+
pickedRef.current = picked;
|
|
1235
|
+
session.selectNode(picked);
|
|
1236
|
+
}, [session, markInteractive]);
|
|
1237
|
+
|
|
1238
|
+
// hovering BORROWS the focus: pointing into this canvas makes it the active one (the tree
|
|
1239
|
+
// follows) for as long as the pointer stays — leaving hands the focus back to the selection
|
|
1240
|
+
// anchor. The hovered node also travels into the session for the tree's pale highlight.
|
|
1241
|
+
const onHover = useCallback((nodeId: string | null, part: string | null) => {
|
|
1242
|
+
markHovered();
|
|
1243
|
+
session.hoverNode(part ?? nodeId);
|
|
1244
|
+
}, [session, markHovered]);
|
|
1245
|
+
|
|
1246
|
+
const pattern = (state.doc as { pattern?: string } | null)?.pattern ?? '/';
|
|
1247
|
+
// previewToken: the token carries the draft identity; the PATH stays the page's real
|
|
1248
|
+
// pattern so modules inside the preview see an honest location. editorBridge NAMES the agent
|
|
1249
|
+
// to attach — derived from this module's own reference, so the preview gets the agent of THIS
|
|
1250
|
+
// editor build. The URL shared with observers is the same address without that parameter.
|
|
1251
|
+
const bridgeId = previewBridgeIdOf(useComponent().reference);
|
|
1252
|
+
// a draft token for editing, an `r<rev>` token for the read-only inspection of a published
|
|
1253
|
+
// revision; a prefab's behind `prefab.` (it previews as a page whose root is its own)
|
|
1254
|
+
const tokenEntity = state.kind === 'prefabs' ? `prefab.${state.pageName}` : state.pageName;
|
|
1255
|
+
const previewToken = state.revision != null ? `${tokenEntity}.r${state.revision}` : `${tokenEntity}.${state.draftId}`;
|
|
1256
|
+
const src = `${pattern}?previewToken=${previewToken}&editorBridge=${bridgeId}`;
|
|
1257
|
+
const frameKey = `${src}#${rebuilds}#${restarts}`;
|
|
1258
|
+
// the address to hand around: the preview without the agent, absolute — a link is for elsewhere
|
|
1259
|
+
const previewHref = typeof window === 'undefined'
|
|
1260
|
+
? `${pattern}?previewToken=${previewToken}`
|
|
1261
|
+
: new URL(`${pattern}?previewToken=${previewToken}`, window.location.origin).href;
|
|
1262
|
+
// Which side of the selection — or of the page, with nothing selected — the pointer is on: the
|
|
1263
|
+
// ghost offered is the one on that side. The pointer reports every frame; only a FLIP of the side
|
|
1264
|
+
// re-renders (setState with the same value is dropped), the geometry is read through a ref
|
|
1265
|
+
const [pointerAtEnd, setPointerAtEnd] = useState(true);
|
|
1266
|
+
const sideRef = useRef<{ rect: IRect | null; axis: TAxis }>({ rect: null, axis: 'column' });
|
|
1267
|
+
const onPointer = useCallback((x: number, y: number) => {
|
|
1268
|
+
const { rect, axis } = sideRef.current;
|
|
1269
|
+
if (!rect) return;
|
|
1270
|
+
setPointerAtEnd(axis === 'row' ? x >= rect.x + rect.width / 2 : y >= rect.y + rect.height / 2);
|
|
1271
|
+
}, []);
|
|
1272
|
+
const { scrolling, boxes, parts, slots, units, viewport, hoveredId, hoveredPart, hoveredCtrl, connected, command, stageRef, overlayRef, frameRef } = usePreviewLink(frameKey, onPick, onHover, onPointer);
|
|
1273
|
+
// the modifier is watched on BOTH sides of the boundary: the preview realm stamps it on its
|
|
1274
|
+
// events, and these listeners catch a press that never generates one (keyboard focus stays on
|
|
1275
|
+
// the editor side — the agent suppresses the mousedowns that would move it)
|
|
1276
|
+
const ctrlHeld = useCtrlHeld();
|
|
1277
|
+
|
|
1278
|
+
// What the LIVE realm can render: the modules it loaded when it booted. Kept as it was at boot
|
|
1279
|
+
// and not widened afterwards — a live update never adds a module, and a block whose type was
|
|
1280
|
+
// taken off the page leaves its module loaded, so putting one back is still free.
|
|
1281
|
+
const bootedRefs = useRef<Set<string>>(new Set());
|
|
1282
|
+
useEffect(() => {
|
|
1283
|
+
bootedRefs.current = new Set(referencesOf(session.snapshot().doc));
|
|
1284
|
+
}, [frameKey, session]);
|
|
1285
|
+
|
|
1286
|
+
// the set as an id, so the two effects below depend on WHAT it is rather than on the array's
|
|
1287
|
+
// identity. Whether it FITS the realm is asked inside them, never during a render: the answer
|
|
1288
|
+
// changes in an effect (a rebuild), and a render that read it would read it stale.
|
|
1289
|
+
const docRefs = useMemo(() => referencesOf(state.doc), [state.doc]);
|
|
1290
|
+
const refsKey = docRefs.join('|');
|
|
1291
|
+
// A PREFAB node is no module: the realm re-expands it from the blocks it already shows, and a
|
|
1292
|
+
// prefab node it shows nothing of (put back by redo after an undo took it out, pasted in) has
|
|
1293
|
+
// only the server's render to come from. So a prefab fits the realm while the agent lists it
|
|
1294
|
+
// among the expanded ones — the booted references stand in until an agent has said
|
|
1295
|
+
const docUnits = useMemo(() => (state.doc ? unitNodeIds(state.doc) : []), [state.doc]);
|
|
1296
|
+
const unitsKey = docUnits.join('|');
|
|
1297
|
+
const unitsRef = useRef(units);
|
|
1298
|
+
unitsRef.current = units;
|
|
1299
|
+
const fitsRealm = (): boolean => docRefs.every((reference) => bootedRefs.current.has(reference))
|
|
1300
|
+
&& (unitsRef.current === null || docUnits.every((id) => unitsRef.current!.has(id)));
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* The place a block is about to go gets ROOM in the preview — the agent splices a little emptiness
|
|
1304
|
+
* into its copy of the page, and the overlay frames it (LandingFrame). Declared before the live
|
|
1305
|
+
* composition below: when the block lands, the place is spent in the same update as the tree
|
|
1306
|
+
* changes, and the room goes away before the block arrives.
|
|
1307
|
+
*/
|
|
1308
|
+
const landingPoint = state.insertionPoint;
|
|
1309
|
+
useEffect(() => {
|
|
1310
|
+
if (!connected) return;
|
|
1311
|
+
command({ t: 'landing', at: landingPoint ? { parentId: landingPoint.parentId, slot: landingPoint.slot, index: landingPoint.index } : null });
|
|
1312
|
+
}, [connected, landingPoint?.parentId, landingPoint?.slot, landingPoint?.index, command]);
|
|
1313
|
+
|
|
1314
|
+
// the catalog's versions: the page's blocks with newer ones (the Updates window, ADR-0061), and the
|
|
1315
|
+
// tree as the preview realm runs it — the editor's own blocks (the page root) at their latest, the
|
|
1316
|
+
// way the server rendered the page, not the version the document was made with. Until the catalog
|
|
1317
|
+
// is known nothing is pushed: the realm holds the server's render of the same document
|
|
1318
|
+
const upgrades = useUpgrades(state.doc, !preview);
|
|
1319
|
+
const catalogKnown = upgrades.components.length > 0;
|
|
1320
|
+
const realmDoc = useMemo(() => (state.doc && catalogKnown ? withLatestTools(state.doc, upgrades.versions) : null), [state.doc, catalogKnown, upgrades.versions]);
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* LIVE COMPOSITION: the tree the editor holds goes straight into the preview's page manager and
|
|
1324
|
+
* the application re-renders around it. No reload, so nothing the realm built up is thrown
|
|
1325
|
+
* away — and no window in which the overlay draws geometry of a tree that has already changed.
|
|
1326
|
+
*
|
|
1327
|
+
* What can degrade: a block that appears this way has no server-rendered data waiting for it,
|
|
1328
|
+
* so its queries fetch from the client. That is a Loading flash where a reload would have shown
|
|
1329
|
+
* the server's answer immediately — and the price of never reloading at all. What goes is the
|
|
1330
|
+
* tree as the realm runs it (`realmDoc` above).
|
|
1331
|
+
*/
|
|
1332
|
+
useEffect(() => {
|
|
1333
|
+
if (!connected || !realmDoc || !fitsRealm()) return;
|
|
1334
|
+
command({ t: 'page', page: realmDoc });
|
|
1335
|
+
}, [connected, realmDoc, refsKey, unitsKey, command]);
|
|
1336
|
+
|
|
1337
|
+
// the author's views of reference providers (i18n: the language being edited) reach the
|
|
1338
|
+
// preview's own store — references there resolve in them, without a reload
|
|
1339
|
+
useEffect(() => {
|
|
1340
|
+
if (connected) command({ t: 'views', views: state.views });
|
|
1341
|
+
}, [connected, state.views, command]);
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* …and the one case that cannot be pushed: a block of a type this realm never loaded. The frame
|
|
1345
|
+
* is rebuilt for it — but only once the draft on disk carries the block, because the shell that
|
|
1346
|
+
* answers `?previewToken` renders the SAVED revision. Until then the preview is one block behind
|
|
1347
|
+
* and says nothing about it, which is the same second the autosave takes anyway.
|
|
1348
|
+
*/
|
|
1349
|
+
useEffect(() => {
|
|
1350
|
+
if (state.doc && !state.dirty && !fitsRealm()) setRebuilds((count) => count + 1);
|
|
1351
|
+
}, [refsKey, unitsKey, state.dirty]);
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Whether the preview is being INSPECTED right now — the toggle, and full screen.
|
|
1355
|
+
*
|
|
1356
|
+
* Full screen is the page and nothing else: it exists to be used, not composed. So the overlay
|
|
1357
|
+
* draws nothing over it and the agent stops swallowing clicks for as long as it lasts — a
|
|
1358
|
+
* frame that covers the screen while links still refuse to open would be the worst of both.
|
|
1359
|
+
* The toggle's own state is untouched; leaving full screen puts the editor back as it was.
|
|
1360
|
+
*/
|
|
1361
|
+
const inspecting = state.inspecting && !fullscreen;
|
|
1362
|
+
useEffect(() => {
|
|
1363
|
+
if (connected) command({ t: 'armed', armed: inspecting });
|
|
1364
|
+
}, [connected, inspecting, command]);
|
|
1365
|
+
|
|
1366
|
+
const selectedId = state.selectedNodeId;
|
|
1367
|
+
useEffect(() => {
|
|
1368
|
+
if (!connected || !selectedId) return;
|
|
1369
|
+
if (pickedRef.current === selectedId) return; // selected from inside — already in view
|
|
1370
|
+
command({ t: 'reveal', nodeId: selectedId });
|
|
1371
|
+
}, [connected, selectedId, command]);
|
|
1372
|
+
|
|
1373
|
+
// the tree cannot see EMPTY slots in the document — the preview's declarations go through the
|
|
1374
|
+
// session (which drops unchanged lists, so per-measure geometry does not wake subscribers)
|
|
1375
|
+
useEffect(() => {
|
|
1376
|
+
session.setDeclaredSlots(slots.map((slot) => ({ parentId: slot.ownerId, slot: slot.slot })));
|
|
1377
|
+
}, [slots, session]);
|
|
1378
|
+
|
|
1379
|
+
const byId = useMemo(() => new Map(boxes.map((box) => [box.id, box])), [boxes]);
|
|
1380
|
+
// a prefab's own block, selected or under the pointer, is framed by its own box
|
|
1381
|
+
const partById = useMemo(() => new Map(parts.map((box) => [box.id, box])), [parts]);
|
|
1382
|
+
const boxOf = (id: string): INodeBox | undefined => byId.get(id) ?? partById.get(id);
|
|
1383
|
+
const selectedBox = selectedId ? boxOf(selectedId) : undefined;
|
|
1384
|
+
// with Ctrl the prefab is the unit — the frame says what a Ctrl-click would take
|
|
1385
|
+
const hoveredShownId = hoveredCtrl || ctrlHeld ? hoveredId : hoveredPart ?? hoveredId;
|
|
1386
|
+
const hoveredBox = hoveredShownId && hoveredShownId !== selectedId ? boxOf(hoveredShownId) : undefined;
|
|
1387
|
+
// The yellow mode: every picked element wears a pick box — pickedness is per element (a flat
|
|
1388
|
+
// set in the session). The frame is THICK where a picked region BEGINS (the element's parent
|
|
1389
|
+
// is not picked) and thin inside it, so what was actually clicked still reads at a glance.
|
|
1390
|
+
const pickedBoxes = state.multiSelectedIds.map((id) => byId.get(id)).filter((box) => box !== undefined);
|
|
1391
|
+
|
|
1392
|
+
// the selected block's settings live beside it — unless the workbench still docks a settings
|
|
1393
|
+
// panel, which then keeps them. Hidden while the page is the visitor's (inspector off), in the
|
|
1394
|
+
// yellow mode and while moving: those modes are about the tree, not one block's settings.
|
|
1395
|
+
const settingsPanels = ensureSettingsPanels(workbenchDi);
|
|
1396
|
+
const panelCount = useSyncExternalStore(settingsPanels.subscribe, settingsPanels.snapshot, settingsPanels.snapshot);
|
|
1397
|
+
// the popover's size is the author's wish, remembered across popovers and reloads
|
|
1398
|
+
const [popoverSize, setPopoverSize] = useState<IPopoverSize>(() => readPopoverSize(localStorageOrNull()));
|
|
1399
|
+
const resizePopover = useCallback((size: IPopoverSize) => {
|
|
1400
|
+
setPopoverSize(size);
|
|
1401
|
+
writePopoverSize(localStorageOrNull(), size);
|
|
1402
|
+
}, []);
|
|
1403
|
+
// what the open form asks for — its height with room enough, a wide field in it
|
|
1404
|
+
const [popoverContent, setPopoverContent] = useState<IPopoverContent>({ height: 0, wide: false });
|
|
1405
|
+
const tellPopoverContent = useCallback((content: IPopoverContent) => setPopoverContent(content), []);
|
|
1406
|
+
// the open settings' form still on its way — the popover waits unseen, the button turns
|
|
1407
|
+
const [settingsLoading, setSettingsLoading] = useState(false);
|
|
1408
|
+
const tellSettingsLoading = useCallback((loading: boolean) => setSettingsLoading(loading), []);
|
|
1409
|
+
// the PAGE's settings wait behind a button among the canvas tools: opening a draft selects its
|
|
1410
|
+
// root, and a popover docked over the page would hide the very controls one opens a draft to
|
|
1411
|
+
// use. Open for ONE document while its root stays selected — picking anything else closes them
|
|
1412
|
+
const docKey = `${state.kind}:${state.pageName}:${state.draftId ?? `r${state.revision}`}`;
|
|
1413
|
+
// PUBLISH from the bar: the draft becomes the live revision, and every session holding it —
|
|
1414
|
+
// this canvas's first — reopens it as that revision, read-only (the pages list does the same
|
|
1415
|
+
// from its rows; the listing refetches itself, the action invalidates it)
|
|
1416
|
+
const hub = editingSessionHubOf(workbenchDi);
|
|
1417
|
+
const publish = useServerAction({ token: publishPageToken, start: EServerActionStart.START_MANUAL });
|
|
1418
|
+
const publishingRef = useRef<{ name: string; draftId: string; kind: ContentKind } | null>(null);
|
|
1419
|
+
// the dialog that names the publication; the listing (the pages views' cache entry) says which
|
|
1420
|
+
// revision this one replaces
|
|
1421
|
+
const [publishAsk, setPublishAsk] = useState(false);
|
|
1422
|
+
const [publishError, setPublishError] = useState<string | null>(null);
|
|
1423
|
+
// the page's blocks with newer versions, and the window that updates them in one step (ADR-0061)
|
|
1424
|
+
const holder = useDi(TILES_HOLDER_TOKEN);
|
|
1425
|
+
const upgradeGroupsOfPage = useMemo(() => upgradeGroups(upgrades.outdated, upgrades.versions), [upgrades.outdated, upgrades.versions]);
|
|
1426
|
+
const [updatesOpen, setUpdatesOpen] = useState(false);
|
|
1427
|
+
const [updating, setUpdating] = useState(false);
|
|
1428
|
+
const runUpdates = async (selected: ReadonlySet<string>) => {
|
|
1429
|
+
const nodes = upgrades.outdated.filter((node) => selected.has(upgradeKey(node.upgrade.namespace, node.upgrade.name)));
|
|
1430
|
+
setUpdating(true);
|
|
1431
|
+
try {
|
|
1432
|
+
session.upgradeNodes(await upgradeChanges(holder, nodes, upgrades.components));
|
|
1433
|
+
notices?.show('publish', blocksLabel('Updated', nodes.length));
|
|
1434
|
+
setUpdatesOpen(false);
|
|
1435
|
+
} catch {
|
|
1436
|
+
notices?.show('refuse', 'Could not update the blocks — nothing was changed');
|
|
1437
|
+
} finally {
|
|
1438
|
+
setUpdating(false);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
const listing = useServerAction({ token: listPagesToken, input: { kind: state.kind } });
|
|
1442
|
+
const listedPage = listing.isSuccess() && listing.serverData.ok
|
|
1443
|
+
? listing.serverData.pages.find((page) => page.name === state.pageName) ?? null
|
|
1444
|
+
: null;
|
|
1445
|
+
const liveOfPage = listedPage?.meta.live ?? null;
|
|
1446
|
+
// a page the platform froze takes no drafts (its revision is a live reference, changed by deploys)
|
|
1447
|
+
const pageLocked = listedPage?.meta.locked === true;
|
|
1448
|
+
// A REVISION's way to an edit: a fresh draft of it, opened in a tab beside — like the pages list's
|
|
1449
|
+
// "New draft"; without the workbench's tabs, in this canvas
|
|
1450
|
+
const createDraft = useServerAction({ token: createPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
1451
|
+
const tabs = useDiOptional(WORKSPACE_TOKEN);
|
|
1452
|
+
useEffect(() => {
|
|
1453
|
+
if (!createDraft.isSuccess() || !createDraft.serverData.ok || state.pageName === null) return;
|
|
1454
|
+
const { draftId, page } = createDraft.serverData;
|
|
1455
|
+
if (tabs) tabs.open({ kind: state.kind, page: state.pageName, draftId }, page);
|
|
1456
|
+
else session.openPage(state.pageName, draftId, page, state.kind);
|
|
1457
|
+
}, [createDraft]);
|
|
1458
|
+
useEffect(() => {
|
|
1459
|
+
if (createDraft.isSuccess() && !createDraft.serverData.ok) notices?.show('refuse', 'Could not make a draft of this revision');
|
|
1460
|
+
}, [createDraft]);
|
|
1461
|
+
useEffect(() => {
|
|
1462
|
+
const asked = publishingRef.current;
|
|
1463
|
+
if (!asked || publish.isLoading()) return;
|
|
1464
|
+
publishingRef.current = null;
|
|
1465
|
+
if (publish.isSuccess() && publish.serverData.ok) {
|
|
1466
|
+
const { live } = publish.serverData;
|
|
1467
|
+
const held = hub ? hub.keys().map((key) => hub.peek(key)).filter((s): s is NonNullable<typeof s> => s !== null) : [session];
|
|
1468
|
+
for (const s of held) {
|
|
1469
|
+
const st = s.snapshot();
|
|
1470
|
+
if (st.kind === asked.kind && st.pageName === asked.name && st.draftId === asked.draftId && st.doc) s.openRevision(asked.name, live, st.doc, asked.kind);
|
|
1471
|
+
}
|
|
1472
|
+
notices?.show('publish', `Published as r${live}`);
|
|
1473
|
+
setPublishAsk(false);
|
|
1474
|
+
} else {
|
|
1475
|
+
const code = publish.isError() ? publish.serverError.code : publish.isSuccess() && !publish.serverData.ok ? publish.serverData.code : 'unknown';
|
|
1476
|
+
const issues = publish.isSuccess() && !publish.serverData.ok ? publish.serverData.issues ?? [] : [];
|
|
1477
|
+
setPublishError([code, ...issues].join('\n'));
|
|
1478
|
+
}
|
|
1479
|
+
}, [publish]);
|
|
1480
|
+
// the standing controls of the page's blocks, a button each in the bar; one card open at a time
|
|
1481
|
+
const { chromes, rootOf } = useChromeNodes(state.doc);
|
|
1482
|
+
const [openChrome, setOpenChrome] = useState<string | null>(null);
|
|
1483
|
+
const openChromeNode = chromes.find((node) => node.id === openChrome) ?? null;
|
|
1484
|
+
// the workbench's tools (ADR-0062): none inside a preview of the workbench itself
|
|
1485
|
+
const editorTools = useDiOptional(EDITOR_TOOLS_TOKEN);
|
|
1486
|
+
const tools = preview ? [] : editorTools?.list() ?? [];
|
|
1487
|
+
// the tool open over this document, and what of it is open, live in the workbench's address
|
|
1488
|
+
// (for the document in front): a reload, a shared link, a tab coming back open the same
|
|
1489
|
+
const toolDesk = useDiOptional(WORKSPACE_TOKEN);
|
|
1490
|
+
const toolTab = dockTab?.id ?? null;
|
|
1491
|
+
const remembered = toolDesk && toolTab ? toolDesk.toolOf(toolTab) : null;
|
|
1492
|
+
const [openTool, setOpenTool] = useState<string | null>(() => (preview ? null : remembered?.tool ?? null));
|
|
1493
|
+
const toolState = useRef<string | null>(remembered?.state ?? null);
|
|
1494
|
+
useEffect(() => {
|
|
1495
|
+
if (toolDesk && toolTab) toolDesk.setTool(toolTab, openTool ? { tool: openTool, state: toolState.current } : null);
|
|
1496
|
+
}, [toolDesk, toolTab, openTool]);
|
|
1497
|
+
const openToolNode = tools.find((node) => node.id === openTool) ?? null;
|
|
1498
|
+
const [pageSettingsFor, setPageSettingsFor] = useState<string | null>(null);
|
|
1499
|
+
const pageSettingsOpen = pageSettingsFor === docKey && selectedId === state.doc?.id;
|
|
1500
|
+
// A block's settings wait behind the button in its size chip: open beside the block they cover
|
|
1501
|
+
// the page one looks at to decide where the next block goes. Any change of the selection turns
|
|
1502
|
+
// them off — reset during the render that sees the change, so the next block never shows them for
|
|
1503
|
+
// a frame; closing them is not stepping out of the selection either
|
|
1504
|
+
const [settingsShown, setSettingsShown] = useState(false);
|
|
1505
|
+
const [settingsSelection, setSettingsSelection] = useState(selectedId);
|
|
1506
|
+
if (settingsSelection !== selectedId) {
|
|
1507
|
+
setSettingsSelection(selectedId);
|
|
1508
|
+
setSettingsShown(false);
|
|
1509
|
+
}
|
|
1510
|
+
useEffect(() => {
|
|
1511
|
+
if (selectedId !== state.doc?.id) setPageSettingsFor(null);
|
|
1512
|
+
}, [selectedId, state.doc?.id]);
|
|
1513
|
+
// a prefab has no route and no head of its own: its root's settings go nowhere. The button
|
|
1514
|
+
// toggles: open settings fold on a second press, and fold with any click away (the selection
|
|
1515
|
+
// leaves the root), like every card of the inspector
|
|
1516
|
+
const openPageSettings = state.doc && state.kind !== 'prefabs'
|
|
1517
|
+
? () => {
|
|
1518
|
+
if (pageSettingsOpen) {
|
|
1519
|
+
setPageSettingsFor(null);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
session.selectNode(state.doc!.id);
|
|
1523
|
+
setPageSettingsFor(docKey);
|
|
1524
|
+
}
|
|
1525
|
+
: null;
|
|
1526
|
+
// a prefab's own block selected: the block as its prefab holds it, under the id the page knows it by
|
|
1527
|
+
const selectedPart = state.doc && isPartId(selectedId) ? prefabPartOf(state.doc, selectedId, project.treeOf) : null;
|
|
1528
|
+
const selectedNode = state.doc && selectedId
|
|
1529
|
+
? findNodeDoc(state.doc, selectedId) ?? (selectedPart ? { ...selectedPart.node, id: selectedId } : null)
|
|
1530
|
+
: null;
|
|
1531
|
+
// …and naming a place for a block is its own mode: the insert popover takes the floor. A block
|
|
1532
|
+
// the preview has not measured (hidden, not rendered) has no chip to open its settings from —
|
|
1533
|
+
// its settings are the one way to it, so they show
|
|
1534
|
+
const isRootSelected = selectedNode !== null && selectedNode.id === state.doc?.id;
|
|
1535
|
+
const settingsAsked = isRootSelected ? pageSettingsOpen : settingsShown || selectedBox === undefined;
|
|
1536
|
+
const showSettings = !preview && panelCount === 0 && selectedNode !== null && inspecting && !state.insertionPoint
|
|
1537
|
+
&& state.multiSelectedIds.length === 0 && !state.movingNodeId && settingsAsked;
|
|
1538
|
+
const stageSize = viewport ?? { width: stageRef.current?.clientWidth ?? 800, height: stageRef.current?.clientHeight ?? 600 };
|
|
1539
|
+
// a block the preview has not measured (hidden, not yet rendered) still has settings: docked
|
|
1540
|
+
// the page's settings open in the stage's top-right corner; any other block's beside the block
|
|
1541
|
+
const settingsPlacement = pageSettingsOpen
|
|
1542
|
+
? (() => {
|
|
1543
|
+
const width = Math.min(popoverSize.width, stageSize.width - 16);
|
|
1544
|
+
const y = 8;
|
|
1545
|
+
return { x: Math.max(8, stageSize.width - width - 8), y, width, maxHeight: Math.max(160, stageSize.height - y - 8), fromBottom: false, side: 'below' as const };
|
|
1546
|
+
})()
|
|
1547
|
+
: placePopover(selectedBox ?? null, stageSize, {
|
|
1548
|
+
top: 8,
|
|
1549
|
+
width: popoverContent.wide ? Math.max(popoverSize.width, wideFieldWidth(stageRef.current, stageSize.width)) : popoverSize.width,
|
|
1550
|
+
contentHeight: popoverContent.height,
|
|
1551
|
+
// laid AGAINST the block — over its frame's 2px, so the two join into one piece (seamOf) —
|
|
1552
|
+
// and above it clear of the corner tags, which stay where they are
|
|
1553
|
+
gap: -2,
|
|
1554
|
+
gapAbove: TAG_HEIGHT,
|
|
1555
|
+
});
|
|
1556
|
+
// where the popover joins the selection's frame: no border of its own there
|
|
1557
|
+
const settingsSeam = !pageSettingsOpen && selectedBox ? seamOf(settingsPlacement, selectedBox) : null;
|
|
1558
|
+
// the card steps out of the way while the page scrolls (it stands where the block no longer is)
|
|
1559
|
+
// and while its block is out of sight — it comes back where the block is once both are over.
|
|
1560
|
+
// Hidden, not closed: the form keeps its focus, its open picker, what is being typed
|
|
1561
|
+
const settingsAway = scrolling || (!pageSettingsOpen && selectedBox !== undefined && outOfSight(selectedBox, stageSize));
|
|
1562
|
+
const startsRegion = (id: string): boolean => {
|
|
1563
|
+
const parent = state.doc ? parentOfNode(state.doc, id) : null;
|
|
1564
|
+
return !parent || !state.multiSelectedIds.includes(parent.id);
|
|
1565
|
+
};
|
|
1566
|
+
const hoverCtrl = hoveredCtrl || ctrlHeld;
|
|
1567
|
+
const point = state.insertionPoint;
|
|
1568
|
+
// A picked place takes its ghost off the screen, and an unpicked one puts it back — neither
|
|
1569
|
+
// sends the pointer anywhere, so a hover from before would survive both and keep drawing a
|
|
1570
|
+
// frame nobody is pointing at. It looked exactly like a place that would not let go.
|
|
1571
|
+
useEffect(() => setHoveredAdd(null), [point]);
|
|
1572
|
+
const isChosen = (at: IInsertionPoint) => point?.parentId === at.parentId && point.slot === at.slot && point.index === at.index;
|
|
1573
|
+
// pressing a ghost NAMES the place, and the insert popover opens beside it with the blocks that
|
|
1574
|
+
// can go there. Pressing the chosen one again takes the choice back.
|
|
1575
|
+
const pickPlace = (at: IInsertionPoint) => {
|
|
1576
|
+
session.setInsertionPoint(isChosen(at) ? null : at);
|
|
1577
|
+
};
|
|
1578
|
+
// the room the agent made where the block will land (LANDING_NODE_ID) — a child the document
|
|
1579
|
+
// does not have, so the slot it stands in counts it off again
|
|
1580
|
+
const landingBox = point ? byId.get(LANDING_NODE_ID) : undefined;
|
|
1581
|
+
const liveSlots = useMemo(() => (point && landingBox
|
|
1582
|
+
? slots.map((slot) => (slot.ownerId === point.parentId && slot.slot === point.slot ? { ...slot, filled: Math.max(0, slot.filled - 1) } : slot))
|
|
1583
|
+
: slots), [slots, point, landingBox]);
|
|
1584
|
+
// what a place lights up on screen: the container it joins — the page's own places light nothing
|
|
1585
|
+
const rectOf = (add: IAddPoint): IRect | null => {
|
|
1586
|
+
if (add.target.of !== 'container') return null;
|
|
1587
|
+
const slot = liveSlots.find((s) => s.ownerId === add.at.parentId && s.slot === add.at.slot);
|
|
1588
|
+
return slot ? drawableArea(slot) : null;
|
|
1589
|
+
};
|
|
1590
|
+
const nameOf = (nodeId: string) => {
|
|
1591
|
+
const box = byId.get(nodeId);
|
|
1592
|
+
return box ? shortName(box.reference) : nodeId;
|
|
1593
|
+
};
|
|
1594
|
+
/**
|
|
1595
|
+
* Where a block may go RIGHT NOW, as ghosts. They follow the selection instead of covering the
|
|
1596
|
+
* page:
|
|
1597
|
+
*
|
|
1598
|
+
* - a selected block offers the places next to it, in its parent's slot — before and after it,
|
|
1599
|
+
* along the way its neighbours run (axisAmong); the pointer's side decides which is shown;
|
|
1600
|
+
* - …and the inside of each EMPTY slot it opens. A slot that holds blocks already is reached
|
|
1601
|
+
* through them: select its first or last block and add next to it;
|
|
1602
|
+
* - with nothing selected — or the page root selected, which is the same thing — the page's own
|
|
1603
|
+
* ends: before the first block and after the last (the pointer's side again), or a single ghost
|
|
1604
|
+
* on an empty page.
|
|
1605
|
+
*
|
|
1606
|
+
* The page root is a special case throughout: its children are rendered by the root component
|
|
1607
|
+
* itself, not through `useChildren`, so no marker is ever planted for it and its geometry comes
|
|
1608
|
+
* from the children's own boxes.
|
|
1609
|
+
*/
|
|
1610
|
+
const ghosts = useMemo((): IGhost[] => {
|
|
1611
|
+
const doc = state.doc;
|
|
1612
|
+
if (!doc) return [];
|
|
1613
|
+
if (state.revision != null) return []; // a revision takes no blocks — no places to offer
|
|
1614
|
+
const pointAt = (
|
|
1615
|
+
parentId: string,
|
|
1616
|
+
slotName: string,
|
|
1617
|
+
index: number,
|
|
1618
|
+
title: string,
|
|
1619
|
+
testId: IAddPoint['testId'],
|
|
1620
|
+
target: TAddTarget = { of: 'container' },
|
|
1621
|
+
): IAddPoint => ({
|
|
1622
|
+
key: `${parentId}:${slotName}:${index}`,
|
|
1623
|
+
title,
|
|
1624
|
+
testId,
|
|
1625
|
+
// the title travels WITH the pick: whoever answers it (the palette) repeats the same
|
|
1626
|
+
// relative words the ghost spoke, instead of translating the place back into node ids
|
|
1627
|
+
at: { parentId, slot: slotName, index, label: title },
|
|
1628
|
+
target,
|
|
1629
|
+
});
|
|
1630
|
+
const roomIn = (parentId: string, slotName: string) => {
|
|
1631
|
+
const declared = liveSlots.find((s) => s.ownerId === parentId && s.slot === slotName);
|
|
1632
|
+
return !declared || hasRoom(declared); // the root's slot is never declared — assume room
|
|
1633
|
+
};
|
|
1634
|
+
const capitalized = (title: string) => `${title.charAt(0).toUpperCase()}${title.slice(1)}`;
|
|
1635
|
+
|
|
1636
|
+
if (!selectedId || selectedId === doc.id) {
|
|
1637
|
+
const children = doc.children ?? [];
|
|
1638
|
+
const drawn = children.map((child) => byId.get(child.id)).filter((box) => box !== undefined);
|
|
1639
|
+
if (!drawn.length) {
|
|
1640
|
+
// nothing on the page yet: one ghost at the top of it
|
|
1641
|
+
const first = pointAt(doc.id, DEFAULT_SLOT, 0, 'add the first block', 'inspector-add-child-after-button', { of: 'page' });
|
|
1642
|
+
return [{ point: first, place: 'free', at: { x: 8, y: 8 }, words: capitalized(first.title), side: 'inside' }];
|
|
1643
|
+
}
|
|
1644
|
+
// the page's own ends: a tag on the top edge of its first block, one hanging from its last
|
|
1645
|
+
const first = drawn[0];
|
|
1646
|
+
const last = drawn[drawn.length - 1];
|
|
1647
|
+
const start = pointAt(doc.id, DEFAULT_SLOT, 0, `add above ${nameOf(children[0].id)}`, 'inspector-add-child-before-button', { of: 'page' });
|
|
1648
|
+
const end = pointAt(doc.id, DEFAULT_SLOT, children.length, `add below ${nameOf(children[children.length - 1].id)}`, 'inspector-add-child-after-button', { of: 'page' });
|
|
1649
|
+
return [
|
|
1650
|
+
{ point: start, place: 'free', at: { x: first.x, y: Math.max(0, first.y - TAG_HEIGHT) }, words: capitalized(start.title), side: 'start' },
|
|
1651
|
+
{ point: end, place: 'free', at: { x: last.x, y: last.y + last.height }, words: capitalized(end.title), side: 'end' },
|
|
1652
|
+
];
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const found: IGhost[] = [];
|
|
1656
|
+
// inside the selected block: a tag in each EMPTY slot, in the top-left corner of its container
|
|
1657
|
+
// (where its resting outline is) — or at its marker, the very point a block would land at
|
|
1658
|
+
for (const slot of liveSlots.filter((s) => s.ownerId === selectedId && hasRoom(s) && s.filled === 0)) {
|
|
1659
|
+
const area = drawableArea(slot);
|
|
1660
|
+
const inside = slotLabel(nameOf(slot.ownerId), slot.slot); // the same name the frame wears
|
|
1661
|
+
const into = pointAt(slot.ownerId, slot.slot, 0, `add into ${inside}`, 'inspector-add-child-after-button');
|
|
1662
|
+
found.push({ point: into, place: 'free', at: area ? { x: area.x, y: area.y } : { x: slot.x, y: slot.y }, words: capitalized(into.title), side: 'inside' });
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// …and next to it, glued to its frame: the parent's slot, at the block's own position and just
|
|
1666
|
+
// after it, on the edges its neighbours say (one under another — top and bottom; side by side —
|
|
1667
|
+
// left and right)
|
|
1668
|
+
const parent = parentOfNode(doc, selectedId);
|
|
1669
|
+
const box = byId.get(selectedId);
|
|
1670
|
+
if (parent && box) {
|
|
1671
|
+
const slotName = findNodeDoc(doc, selectedId)?.slot ?? DEFAULT_SLOT;
|
|
1672
|
+
const siblings = (parent.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slotName);
|
|
1673
|
+
const index = siblings.findIndex((child) => child.id === selectedId);
|
|
1674
|
+
if (index >= 0 && roomIn(parent.id, slotName)) {
|
|
1675
|
+
const row = axisAmong(siblings.map((child) => byId.get(child.id)), index) === 'row';
|
|
1676
|
+
const target: TAddTarget = parent.id === doc.id ? { of: 'page' } : { of: 'container' };
|
|
1677
|
+
const name = nameOf(selectedId);
|
|
1678
|
+
const before = pointAt(parent.id, slotName, index, `add before ${name}`, 'inspector-add-before-button', target);
|
|
1679
|
+
const after = pointAt(parent.id, slotName, index + 1, `add after ${name}`, 'inspector-add-after-button', target);
|
|
1680
|
+
found.push({ point: before, place: row ? 'left' : 'top', at: null, words: row ? capitalized(before.title) : 'Add above', side: 'start' });
|
|
1681
|
+
found.push({ point: after, place: row ? 'right' : 'bottom', at: null, words: row ? capitalized(after.title) : 'Add below', side: 'end' });
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return found;
|
|
1685
|
+
}, [state.doc, state.revision, selectedId, liveSlots, byId]);
|
|
1686
|
+
|
|
1687
|
+
// what the pointer's side is read against: the selected block — or the page, its blocks' span
|
|
1688
|
+
const sideGhost = ghosts.find((ghost) => ghost.side !== 'inside');
|
|
1689
|
+
const pageBlocks = !selectedId || selectedId === state.doc?.id
|
|
1690
|
+
? (state.doc?.children ?? []).map((child) => byId.get(child.id)).filter((box) => box !== undefined)
|
|
1691
|
+
: [];
|
|
1692
|
+
sideRef.current = {
|
|
1693
|
+
rect: selectedId && selectedId !== state.doc?.id
|
|
1694
|
+
? (selectedBox ?? null)
|
|
1695
|
+
: pageBlocks.length
|
|
1696
|
+
? { x: 0, y: pageBlocks[0].y, width: viewport?.width ?? 0, height: pageBlocks[pageBlocks.length - 1].y + pageBlocks[pageBlocks.length - 1].height - pageBlocks[0].y }
|
|
1697
|
+
: null,
|
|
1698
|
+
axis: sideGhost?.place === 'left' || sideGhost?.place === 'right' ? 'row' : 'column',
|
|
1699
|
+
};
|
|
1700
|
+
// on screen: what is inside always, of the two sides only the pointer's — the glued one goes
|
|
1701
|
+
// onto the selection's frame, the free ones stand on their own
|
|
1702
|
+
const shownGhosts = ghosts.filter((ghost) => ghost.side === 'inside' || (ghost.side === 'end') === pointerAtEnd);
|
|
1703
|
+
const gluedGhost = shownGhosts.find((ghost) => ghost.place !== 'free');
|
|
1704
|
+
// the popover lying under the block takes the bottom edge — the ghost below rides on the seam,
|
|
1705
|
+
// in the popover's head, still right under the block
|
|
1706
|
+
const ghostOnSeam = showSettings && !settingsLoading && settingsSeam?.edge === 'top' && gluedGhost?.place === 'bottom' && pickedBoxes.length === 0 ? gluedGhost : undefined;
|
|
1707
|
+
const freeGhosts = shownGhosts.filter((ghost) => ghost.place === 'free');
|
|
1708
|
+
|
|
1709
|
+
// the picked place is found among ALL the ghosts, both sides — the pointer wandering to the other
|
|
1710
|
+
// half while the picker is open must not lose the place it named
|
|
1711
|
+
const pickedGhost = point ? ghosts.find((ghost) => isChosen(ghost.point.at)) : undefined;
|
|
1712
|
+
const pickedAdd = pickedGhost?.point;
|
|
1713
|
+
// a hover only counts while its ghost is still on screen: pressing one takes it away without a
|
|
1714
|
+
// pointer-leave, and a hover left behind would freeze a frame on the overlay
|
|
1715
|
+
const hovered = hoveredAdd && shownGhosts.some((ghost) => ghost.point.key === hoveredAdd.key) ? hoveredAdd : null;
|
|
1716
|
+
const marked = pickedAdd ?? (point ? null : hovered);
|
|
1717
|
+
const markedRect = marked ? rectOf(marked) : null;
|
|
1718
|
+
// the insert popover stands beside the place: the room made for the block once the preview has
|
|
1719
|
+
// made it — until then the block whose ghost was pressed, or the free tag itself
|
|
1720
|
+
const insertPlacement = (() => {
|
|
1721
|
+
if (!point) return null;
|
|
1722
|
+
const anchor = landingBox
|
|
1723
|
+
?? (pickedGhost?.place !== 'free' ? selectedBox : undefined)
|
|
1724
|
+
?? (pickedGhost?.at ? { ...pickedGhost.at, width: INSERT_POPOVER_WIDTH / 2, height: TAG_HEIGHT } : null);
|
|
1725
|
+
return placePopover(anchor, stageSize, { top: 44, width: INSERT_POPOVER_WIDTH, minHeight: 260 });
|
|
1726
|
+
})();
|
|
1727
|
+
|
|
1728
|
+
// every place the SELECTED block offers, filled ones included: "what can go inside this block"
|
|
1729
|
+
// is the question a selection asks, and a slot with children is still a place
|
|
1730
|
+
const restingSlots = marked ? [] : liveSlots
|
|
1731
|
+
.filter((slot) => slot.ownerId === selectedId)
|
|
1732
|
+
.map((slot) => ({ slot, area: drawableArea(slot) }))
|
|
1733
|
+
.filter((entry): entry is { slot: ISlotBox; area: NonNullable<ISlotBox['area']> } => entry.area !== null);
|
|
1734
|
+
|
|
1735
|
+
// inside a preview realm the canvas must NOT spawn another preview (iframe-in-iframe forever
|
|
1736
|
+
// when the workbench page previews itself)
|
|
1737
|
+
if (preview) {
|
|
1738
|
+
return <div className="canvas-view canvas-view__empty"
|
|
1739
|
+
data-canvas-preview-stub>A preview inside a preview does not expand</div>;
|
|
1740
|
+
}
|
|
1741
|
+
if (!state.doc) {
|
|
1742
|
+
return <div className="canvas-view canvas-view__empty"
|
|
1743
|
+
data-canvas-empty>The preview of the opened page will appear here</div>;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
return (
|
|
1747
|
+
<TooltipHost>
|
|
1748
|
+
{/* dock-panel-flush: opt-in to the dock's full-bleed panel contract (no padding around the iframe) */}
|
|
1749
|
+
<div className={`canvas-view dock-panel-flush${fullscreen ? ' canvas-view--fullscreen' : ''}`}
|
|
1750
|
+
data-canvas={state.pageName ?? undefined}
|
|
1751
|
+
data-fullscreen={fullscreen || undefined}
|
|
1752
|
+
/* a pointerDOWN inside this canvas is deliberate — the focus stays; a mere hover only
|
|
1753
|
+
BORROWS it (markHovered), and the leave returns a still-borrowed focus to the
|
|
1754
|
+
selection anchor (markLeft), taking the pale tree highlight along */
|
|
1755
|
+
onPointerDownCapture={markInteractive}
|
|
1756
|
+
onPointerEnter={markHovered}
|
|
1757
|
+
onPointerLeave={() => {
|
|
1758
|
+
session.hoverNode(null);
|
|
1759
|
+
markLeft();
|
|
1760
|
+
}}>
|
|
1761
|
+
{/* the BUTTON shows the toggle's own state, not the full-screen override — leaving full
|
|
1762
|
+
screen puts the editor back exactly as it was found */}
|
|
1763
|
+
<ActionBar inspecting={state.inspecting}
|
|
1764
|
+
chromes={chromes}
|
|
1765
|
+
openChrome={openChrome}
|
|
1766
|
+
onChrome={(id) => setOpenChrome((open) => (open === id ? null : id))}
|
|
1767
|
+
tools={tools.map((node) => ({
|
|
1768
|
+
id: node.id,
|
|
1769
|
+
label: toolLabelOf(node),
|
|
1770
|
+
iconUrl: upgrades.components.find((c) => c.id === node.reference)?.iconUrl,
|
|
1771
|
+
}))}
|
|
1772
|
+
openTool={openToolNode ? openToolNode.id : null}
|
|
1773
|
+
onTool={(id) => {
|
|
1774
|
+
// another tool, or none: what was open of the last one is not its
|
|
1775
|
+
toolState.current = null;
|
|
1776
|
+
setOpenTool((open) => (open === id ? null : id));
|
|
1777
|
+
}}
|
|
1778
|
+
pageSettingsOpen={pageSettingsOpen}
|
|
1779
|
+
onPageSettings={openPageSettings}
|
|
1780
|
+
version={state.revision != null
|
|
1781
|
+
? { kind: 'revision', rev: state.revision }
|
|
1782
|
+
: state.draftId !== null
|
|
1783
|
+
? { kind: 'draft', name: (state.doc as { title?: string } | null)?.title ?? state.draftId.slice(0, 8), saving: state.dirty }
|
|
1784
|
+
: null}
|
|
1785
|
+
updates={state.revision == null && state.doc !== null && upgrades.outdated.length ? {
|
|
1786
|
+
count: upgrades.outdated.length,
|
|
1787
|
+
onOpen: () => setUpdatesOpen(true),
|
|
1788
|
+
} : null}
|
|
1789
|
+
newDraft={state.revision != null && state.pageName !== null && !pageLocked ? {
|
|
1790
|
+
busy: createDraft.isLoading(),
|
|
1791
|
+
onNewDraft: () => createDraft.start!({ name: state.pageName!, fromRev: state.revision!, kind: state.kind }),
|
|
1792
|
+
} : null}
|
|
1793
|
+
canCut={state.revision == null && session.copySelection().length > 0}
|
|
1794
|
+
canCopy={session.copySelection().length > 0}
|
|
1795
|
+
canPaste={state.revision == null && state.doc !== null
|
|
1796
|
+
&& typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText)}
|
|
1797
|
+
canUndo={state.revision == null && (state.doc?.history?.length ?? 0) > 0}
|
|
1798
|
+
canRedo={state.revision == null && (state.doc?.redo?.length ?? 0) > 0}
|
|
1799
|
+
previewHref={previewHref}
|
|
1800
|
+
publish={state.draftId !== null && state.pageName !== null ? {
|
|
1801
|
+
busy: publishingRef.current !== null && publish.isLoading(),
|
|
1802
|
+
dirty: state.dirty,
|
|
1803
|
+
onPublish: () => {
|
|
1804
|
+
setPublishError(null);
|
|
1805
|
+
setPublishAsk(true);
|
|
1806
|
+
},
|
|
1807
|
+
} : null}
|
|
1808
|
+
onShare={() => {
|
|
1809
|
+
// the WINDOW's clipboard: this is browser code, and a test realm's bare
|
|
1810
|
+
// `navigator` is the host's, not the document's
|
|
1811
|
+
const clipboard = typeof window === 'undefined' ? undefined : window.navigator.clipboard;
|
|
1812
|
+
if (!clipboard?.writeText) {
|
|
1813
|
+
notices?.show('link', 'Copying is not available here');
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
clipboard.writeText(previewHref)
|
|
1817
|
+
.then(() => notices?.show('link', 'Preview link copied'))
|
|
1818
|
+
.catch(() => notices?.show('link', 'Could not copy the link'));
|
|
1819
|
+
}}
|
|
1820
|
+
onRestart={() => setRestarts((count) => count + 1)}
|
|
1821
|
+
onFullscreen={() => setFullscreen(true)}
|
|
1822
|
+
onInspecting={(on) => session.setInspecting(on)}
|
|
1823
|
+
onCut={() => {
|
|
1824
|
+
const blocks = session.copySelection();
|
|
1825
|
+
if (!blocks.length) return;
|
|
1826
|
+
void navigator.clipboard?.writeText(serializeBlocks(blocks));
|
|
1827
|
+
session.removeSelection();
|
|
1828
|
+
notices?.show('cut', blocksLabel('Cut', blocks.length));
|
|
1829
|
+
}}
|
|
1830
|
+
onCopy={() => {
|
|
1831
|
+
const blocks = session.copySelection();
|
|
1832
|
+
if (!blocks.length) return;
|
|
1833
|
+
void navigator.clipboard?.writeText(serializeBlocks(blocks));
|
|
1834
|
+
notices?.show('copy', blocksLabel('Copied', blocks.length));
|
|
1835
|
+
}}
|
|
1836
|
+
onPaste={() => {
|
|
1837
|
+
void navigator.clipboard?.readText().then((text) => {
|
|
1838
|
+
const blocks = parseBlocks(text);
|
|
1839
|
+
if (!blocks) return;
|
|
1840
|
+
session.pasteNodes(blocks);
|
|
1841
|
+
notices?.show('paste', blocksLabel('Pasted', blocks.length));
|
|
1842
|
+
}).catch(() => undefined); // permission refused → nothing to paste
|
|
1843
|
+
}}
|
|
1844
|
+
onUndo={() => {
|
|
1845
|
+
session.undo(); // the button is disabled on an empty stack — the step happened
|
|
1846
|
+
notices?.show('undo', 'Undone');
|
|
1847
|
+
}}
|
|
1848
|
+
onRedo={() => {
|
|
1849
|
+
session.redo();
|
|
1850
|
+
notices?.show('redo', 'Redone');
|
|
1851
|
+
}} />
|
|
1852
|
+
<div className="canvas-view__stage"
|
|
1853
|
+
ref={stageRef}>
|
|
1854
|
+
{fullscreen ? (
|
|
1855
|
+
<button type="button"
|
|
1856
|
+
className="canvas-view__close"
|
|
1857
|
+
data-test-id="canvas-exit-fullscreen-button"
|
|
1858
|
+
data-action="exit-fullscreen"
|
|
1859
|
+
data-tooltip="Leave full screen"
|
|
1860
|
+
aria-label="Leave full screen"
|
|
1861
|
+
onClick={() => setFullscreen(false)}>
|
|
1862
|
+
<svg viewBox="0 0 16 16"
|
|
1863
|
+
aria-hidden="true">
|
|
1864
|
+
<path d="M4.5 4.5l7 7M11.5 4.5l-7 7"
|
|
1865
|
+
stroke="currentColor"
|
|
1866
|
+
strokeWidth="1.8" />
|
|
1867
|
+
</svg>
|
|
1868
|
+
</button>
|
|
1869
|
+
) : null}
|
|
1870
|
+
<iframe key={frameKey}
|
|
1871
|
+
ref={frameRef}
|
|
1872
|
+
className="canvas-view__frame"
|
|
1873
|
+
title={state.revision != null
|
|
1874
|
+
? `preview: ${tokenEntity} r${state.revision}`
|
|
1875
|
+
: `preview: ${tokenEntity} draft ${state.draftId}`}
|
|
1876
|
+
data-preview-src={src}
|
|
1877
|
+
/* which build of the frame this is: bumped only when the preview cannot be
|
|
1878
|
+
updated in place, so a save that changes nothing structural leaves it alone */
|
|
1879
|
+
data-frame-build={rebuilds}
|
|
1880
|
+
src={src} />
|
|
1881
|
+
<div className="canvas-view__overlay"
|
|
1882
|
+
data-test-id="inspector"
|
|
1883
|
+
data-overlay-connected={connected || undefined}
|
|
1884
|
+
data-overlay-boxes={boxes.length}
|
|
1885
|
+
data-overlay-slots={slots.length}>
|
|
1886
|
+
<div className="canvas-view__layer"
|
|
1887
|
+
ref={overlayRef}>
|
|
1888
|
+
{/* inspector off: the page is the visitor's for a moment, and nothing is drawn on it */}
|
|
1889
|
+
{inspecting && marked && marked.target.of === 'container' ? (
|
|
1890
|
+
/* picked: everything else steps back, so the green frame on screen answers "which
|
|
1891
|
+
block does it join" — and the palette wears the same frame. Hovered: the same
|
|
1892
|
+
frame, shown before the click rather than after it. A place of the page itself
|
|
1893
|
+
lights nothing: the page is no container, its ghost is the answer */
|
|
1894
|
+
<TargetFrame rect={markedRect}
|
|
1895
|
+
area={`${marked.at.parentId}:${marked.at.slot}`}
|
|
1896
|
+
label={slotLabel(nameOf(marked.at.parentId), marked.at.slot)} />
|
|
1897
|
+
) : null}
|
|
1898
|
+
{/* …and the very spot, once the preview has made room for it */}
|
|
1899
|
+
{inspecting && point && landingBox ? <LandingFrame box={landingBox} /> : null}
|
|
1900
|
+
{inspecting && !point ? (
|
|
1901
|
+
<>
|
|
1902
|
+
{restingSlots.map(({ slot, area }) => (
|
|
1903
|
+
<SlotArea key={`area:${slot.ownerId}:${slot.slot}`}
|
|
1904
|
+
slot={slot}
|
|
1905
|
+
area={area} />
|
|
1906
|
+
))}
|
|
1907
|
+
{/* the slot AS the selection (picked in the tree): its container wears the green
|
|
1908
|
+
child box — the same frame an insert lights, because that is what it means */}
|
|
1909
|
+
{(() => {
|
|
1910
|
+
const sel = state.selectedSlot;
|
|
1911
|
+
const declared = sel
|
|
1912
|
+
? slots.find((s) => s.ownerId === sel.parentId && s.slot === sel.slot)
|
|
1913
|
+
: undefined;
|
|
1914
|
+
if (!sel || !declared) return null;
|
|
1915
|
+
return <TargetFrame rect={drawableArea(declared)}
|
|
1916
|
+
area={`${sel.parentId}:${sel.slot}`}
|
|
1917
|
+
label={slotLabel(nameOf(sel.parentId), sel.slot)} />;
|
|
1918
|
+
})()}
|
|
1919
|
+
{hoveredBox ? <Outline box={hoveredBox}
|
|
1920
|
+
kind={hoverCtrl ? 'hover-pick' : 'hover'} /> : null}
|
|
1921
|
+
{/* every picked element is shown; the region's first element wears the thick frame */}
|
|
1922
|
+
{pickedBoxes.map((box) => (
|
|
1923
|
+
<Outline key={`pick:${box.id}`}
|
|
1924
|
+
box={box}
|
|
1925
|
+
kind={startsRegion(box.id) ? 'pick' : 'pick-covered'} />
|
|
1926
|
+
))}
|
|
1927
|
+
{selectedBox ? (
|
|
1928
|
+
<Outline box={selectedBox}
|
|
1929
|
+
kind="select"
|
|
1930
|
+
size
|
|
1931
|
+
within={stageSize}
|
|
1932
|
+
label={shortName(selectedBox.reference)}
|
|
1933
|
+
onSettings={selectedBox.id === state.doc.id || selectedNode === null
|
|
1934
|
+
? undefined // the page's settings live behind the bar's button; a library prefab's block has none to show
|
|
1935
|
+
: () => setSettingsShown((shown) => !shown)}
|
|
1936
|
+
settingsOpen={settingsShown}
|
|
1937
|
+
settingsLoading={showSettings && settingsLoading}
|
|
1938
|
+
// the ghosts belong to the insert flow — the yellow mode holds the floor
|
|
1939
|
+
ghost={gluedGhost && gluedGhost.place !== 'free' && !ghostOnSeam && pickedBoxes.length === 0 ? {
|
|
1940
|
+
place: gluedGhost.place,
|
|
1941
|
+
node: <GhostTab ghost={gluedGhost}
|
|
1942
|
+
height={Math.min(selectedBox.height, SIDE_TAB_MAX_HEIGHT)}
|
|
1943
|
+
onPick={pickPlace}
|
|
1944
|
+
onHover={setHoveredAdd} />,
|
|
1945
|
+
} : undefined}
|
|
1946
|
+
onRemove={selectedBox.id === state.doc.id || state.revision != null || isPartId(selectedBox.id)
|
|
1947
|
+
? undefined // no page without its root; no removals in a read-only view; a prefab's block is the prefab's
|
|
1948
|
+
: () => session.removeNode(selectedBox.id)} />
|
|
1949
|
+
) : null}
|
|
1950
|
+
{pickedBoxes.length === 0 && freeGhosts.map((ghost) => (
|
|
1951
|
+
<GhostTab key={ghost.point.key}
|
|
1952
|
+
ghost={ghost}
|
|
1953
|
+
onPick={pickPlace}
|
|
1954
|
+
onHover={setHoveredAdd} />
|
|
1955
|
+
))}
|
|
1956
|
+
</>
|
|
1957
|
+
) : null}
|
|
1958
|
+
</div>
|
|
1959
|
+
</div>
|
|
1960
|
+
{!preview && !fullscreen && openChromeNode ? <ChromeCard node={openChromeNode}
|
|
1961
|
+
rootReference={rootOf(openChromeNode)} /> : null}
|
|
1962
|
+
{!fullscreen && openToolNode ? <ToolPanel key={openToolNode.id}
|
|
1963
|
+
node={openToolNode}
|
|
1964
|
+
instance={bridgeId}
|
|
1965
|
+
session={session}
|
|
1966
|
+
previewHref={previewHref}
|
|
1967
|
+
frame={frameRef}
|
|
1968
|
+
state={{
|
|
1969
|
+
read: () => toolState.current,
|
|
1970
|
+
write: (next) => {
|
|
1971
|
+
toolState.current = next;
|
|
1972
|
+
if (toolDesk && toolTab) toolDesk.setTool(toolTab, { tool: openToolNode.id, state: next });
|
|
1973
|
+
},
|
|
1974
|
+
}}
|
|
1975
|
+
onClose={() => {
|
|
1976
|
+
toolState.current = null;
|
|
1977
|
+
setOpenTool(null);
|
|
1978
|
+
}} /> : null}
|
|
1979
|
+
{point && insertPlacement && inspecting && !fullscreen ? (
|
|
1980
|
+
<InsertPopover session={session}
|
|
1981
|
+
point={point}
|
|
1982
|
+
placement={insertPlacement} />
|
|
1983
|
+
) : null}
|
|
1984
|
+
{updatesOpen && upgradeGroupsOfPage.length && typeof document !== 'undefined' ? (
|
|
1985
|
+
<UpdatesModal host={document}
|
|
1986
|
+
groups={upgradeGroupsOfPage}
|
|
1987
|
+
busy={updating}
|
|
1988
|
+
onCancel={() => setUpdatesOpen(false)}
|
|
1989
|
+
onConfirm={(selected) => void runUpdates(selected)} />
|
|
1990
|
+
) : null}
|
|
1991
|
+
{publishAsk && state.draftId !== null && state.pageName !== null && typeof document !== 'undefined' ? (
|
|
1992
|
+
<PublishModal host={document}
|
|
1993
|
+
noun={state.kind === 'prefabs' ? 'prefab' : 'page'}
|
|
1994
|
+
route={state.kind === 'prefabs' ? state.pageName : pattern}
|
|
1995
|
+
suggested={(state.doc as { title?: string } | null)?.title ?? state.draftId.slice(0, 8)}
|
|
1996
|
+
live={liveOfPage}
|
|
1997
|
+
publishing={publishingRef.current !== null && publish.isLoading()}
|
|
1998
|
+
error={publishError}
|
|
1999
|
+
onCancel={() => setPublishAsk(false)}
|
|
2000
|
+
onConfirm={(title) => {
|
|
2001
|
+
if (state.draftId === null || state.pageName === null) return;
|
|
2002
|
+
setPublishError(null);
|
|
2003
|
+
publishingRef.current = { name: state.pageName, draftId: state.draftId, kind: state.kind };
|
|
2004
|
+
publish.start!({ name: state.pageName, draftId: state.draftId, kind: state.kind, title });
|
|
2005
|
+
}} />
|
|
2006
|
+
) : null}
|
|
2007
|
+
{showSettings && selectedNode ? (
|
|
2008
|
+
<SettingsPopover session={session}
|
|
2009
|
+
state={state}
|
|
2010
|
+
hidden={settingsAway}
|
|
2011
|
+
placement={settingsPlacement}
|
|
2012
|
+
size={popoverSize}
|
|
2013
|
+
onResize={resizePopover}
|
|
2014
|
+
onContent={tellPopoverContent}
|
|
2015
|
+
onLoading={tellSettingsLoading}
|
|
2016
|
+
// putting the settings away keeps the selection: they were in the
|
|
2017
|
+
// way, the block was not
|
|
2018
|
+
onClose={isRootSelected ? () => setPageSettingsFor(null) : () => setSettingsShown(false)}
|
|
2019
|
+
seam={settingsSeam}
|
|
2020
|
+
seamGhost={ghostOnSeam ? <GhostTab ghost={ghostOnSeam}
|
|
2021
|
+
onPick={pickPlace}
|
|
2022
|
+
onHover={setHoveredAdd} /> : null}
|
|
2023
|
+
stage={stageSize}
|
|
2024
|
+
reference={selectedNode.reference}
|
|
2025
|
+
placed={upgrades.versions.placedAt(selectedNode.reference)} />
|
|
2026
|
+
) : null}
|
|
2027
|
+
</div>
|
|
2028
|
+
</div>
|
|
2029
|
+
</TooltipHost>
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
export default markInternalTile(defineComponentSide<ICanvasSettings>({
|
|
2034
|
+
Component: Canvas,
|
|
2035
|
+
defaultSettings() {
|
|
2036
|
+
return {};
|
|
2037
|
+
},
|
|
2038
|
+
}));
|