@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,514 @@
|
|
|
1
|
+
// Smart widget: the edited page's node OUTLINE — selection, node removal, and the MOVE mode. The
|
|
2
|
+
// live-preview stays pure in `canvas`; this is the structural view.
|
|
3
|
+
//
|
|
4
|
+
// It shows whether the draft is on disk, but does not put it there: writing is the workbench's
|
|
5
|
+
// business (EditingAutosave, armed by useEditingSession), not this panel's. It used to live here,
|
|
6
|
+
// which meant closing this tab silently stopped saving the page.
|
|
7
|
+
import { defineComponentSide, markInternalTile } from '@mosetta/react';
|
|
8
|
+
import '../content/workbenchScale.css';
|
|
9
|
+
import './treeView.css';
|
|
10
|
+
import { CSSProperties, Fragment, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { DEFAULT_SLOT } from '@mosetta/react';
|
|
12
|
+
import { EditingSession, findNodeDoc, IEditingState, IBlueprintNodeDoc, isOutletReference, unitOf } from '@mosetta/content';
|
|
13
|
+
import { unitOwnerOf, UNIT_ID_SEPARATOR } from '@mosetta/content/units';
|
|
14
|
+
import { useEditingSession } from '../content/useEditingSession';
|
|
15
|
+
import { TooltipHost } from '../content/tooltips';
|
|
16
|
+
import { useCtrlHeld } from '../content/useCtrlHeld';
|
|
17
|
+
import { blocksLabel, EditingNotices, noticesOf } from '../content/editingNotices';
|
|
18
|
+
import { useDi, useDiOptional, useIsPreviewMode } from '@mosetta/react';
|
|
19
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
20
|
+
import { DOCK_TAB_TOKEN } from '@mosetta/ui';
|
|
21
|
+
import { useProjectPrefabs } from '../content/projectPrefabs';
|
|
22
|
+
import { disconnectedOf, useDisabledDistributions } from '../content/disconnected';
|
|
23
|
+
import { useUpgrades } from '../content/blockUpgrades';
|
|
24
|
+
import type { IUpgrade } from '../content/catalogVersions';
|
|
25
|
+
import type { IPrefabTree } from '../content/contentCommon';
|
|
26
|
+
|
|
27
|
+
const shortName = (reference: string) => {
|
|
28
|
+
const unit = unitOf(reference);
|
|
29
|
+
if (unit !== null) return `prefab · ${unit.name}`;
|
|
30
|
+
return /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// A TOP-LEVEL component on purpose: defined inside TreeView it would be a NEW element type on
|
|
34
|
+
// every render, and react would unmount and remount every row — the panel's scroll clamps to the
|
|
35
|
+
// top the moment the old rows leave. Stable identity → rows update in place, scroll survives.
|
|
36
|
+
interface INodeRowProps {
|
|
37
|
+
readonly node: IBlueprintNodeDoc;
|
|
38
|
+
readonly depth: number;
|
|
39
|
+
readonly doc: IBlueprintNodeDoc;
|
|
40
|
+
readonly state: IEditingState;
|
|
41
|
+
readonly session: EditingSession;
|
|
42
|
+
readonly notices: EditingNotices | null;
|
|
43
|
+
/** the travelling node with its whole subtree — rows tinted, gaps inside offered to nobody */
|
|
44
|
+
readonly movingFamily: ReadonlySet<string>;
|
|
45
|
+
/** a published revision is inspected: rows select, nothing offers to change the tree */
|
|
46
|
+
readonly readOnly: boolean;
|
|
47
|
+
/** the live tree a prefab node embeds, when it is the project's own */
|
|
48
|
+
readonly prefabTreeOf: (reference: string) => IPrefabTree | null;
|
|
49
|
+
/** the branches folded away, by node id — the whole document is in view unless folded */
|
|
50
|
+
readonly folded: ReadonlySet<string>;
|
|
51
|
+
readonly onFold: (nodeId: string) => void;
|
|
52
|
+
/** the distributions the project disconnected — their blocks are marked (the site leaves them out) */
|
|
53
|
+
readonly off: readonly string[];
|
|
54
|
+
/** the nodes whose block has a newer version, by id — marked with it (ADR-0061) */
|
|
55
|
+
readonly upgrades: ReadonlyMap<string, IUpgrade>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FoldIcon = (
|
|
59
|
+
<svg viewBox="0 0 16 16"
|
|
60
|
+
aria-hidden="true">
|
|
61
|
+
<path d="M6 3.5L10.5 8 6 12.5"
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth="1.6"
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeLinejoin="round" />
|
|
67
|
+
</svg>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The blocks a PREFAB brings, under its node — to be seen, not changed: they are the prefab's, and
|
|
72
|
+
* change in the prefab itself. A click on one selects it as itself, read-only — framed in the
|
|
73
|
+
* canvas, its settings shown with the way to the prefab (prefabParts.ts); the outlet is the page's
|
|
74
|
+
* place, not a block, and a click on it selects the prefab node. The node's own blocks (the page's)
|
|
75
|
+
* sit where the prefab's OUTLET is, editable as ever.
|
|
76
|
+
*/
|
|
77
|
+
function PrefabRows({ nodes, depth, owner, state, session, body }: {
|
|
78
|
+
nodes: readonly IBlueprintNodeDoc[];
|
|
79
|
+
depth: number;
|
|
80
|
+
owner: string;
|
|
81
|
+
state: IEditingState;
|
|
82
|
+
session: EditingSession;
|
|
83
|
+
body: (depth: number) => ReactNode;
|
|
84
|
+
}) {
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
{nodes.map((n) => {
|
|
88
|
+
const outlet = isOutletReference(n.reference);
|
|
89
|
+
// the id the page knows the block by — the preview's expanded one
|
|
90
|
+
const partId = `${owner}${UNIT_ID_SEPARATOR}${n.id}`;
|
|
91
|
+
const selected = !outlet && state.selectedNodeId === partId;
|
|
92
|
+
const hovered = !outlet && !selected && state.hoveredNodeId === partId;
|
|
93
|
+
return (
|
|
94
|
+
<Fragment key={n.id}>
|
|
95
|
+
<div className={`tree-view__node tree-view__node--prefab${selected ? ' tree-view__node--selected' : ''}${hovered ? ' tree-view__node--hovered' : ''}`}
|
|
96
|
+
data-test-id="tree-prefab-row"
|
|
97
|
+
data-prefab-block={n.id}
|
|
98
|
+
data-part-id={outlet ? undefined : partId}
|
|
99
|
+
data-selected={selected || undefined}
|
|
100
|
+
data-tooltip={outlet
|
|
101
|
+
? 'Where the page\'s own blocks go inside the prefab'
|
|
102
|
+
: 'A block of the prefab — seen here, changed in the prefab itself'}
|
|
103
|
+
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
104
|
+
onClick={(e) => {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
session.selectNode(outlet ? owner : partId);
|
|
107
|
+
}}>
|
|
108
|
+
<span className="tree-view__label">{shortName(n.reference)}</span>
|
|
109
|
+
</div>
|
|
110
|
+
{outlet
|
|
111
|
+
? body(depth)
|
|
112
|
+
: <PrefabRows nodes={n.children ?? []}
|
|
113
|
+
depth={depth + 1}
|
|
114
|
+
owner={owner}
|
|
115
|
+
state={state}
|
|
116
|
+
session={session}
|
|
117
|
+
body={body} />}
|
|
118
|
+
</Fragment>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** the ids of the nodes above `id`, the root first — empty for the root and for an unknown id */
|
|
126
|
+
function ancestorsOf(root: IBlueprintNodeDoc, id: string): string[] {
|
|
127
|
+
const walk = (node: IBlueprintNodeDoc, trail: string[]): string[] | null => {
|
|
128
|
+
if (node.id === id) return trail;
|
|
129
|
+
for (const child of node.children ?? []) {
|
|
130
|
+
const found = walk(child, [...trail, node.id]);
|
|
131
|
+
if (found) return found;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
return walk(root, []) ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const hasOutlet = (nodes: readonly IBlueprintNodeDoc[]): boolean => nodes.some((n) => isOutletReference(n.reference) || hasOutlet(n.children ?? []));
|
|
139
|
+
|
|
140
|
+
// EVERY slot with children gets its own titled section — the default one included: the outline
|
|
141
|
+
// shows WHERE a node sits, always. A slot row is not a node; it is a DOOR to its children —
|
|
142
|
+
// a Ctrl-click on it picks (or releases) all of them, the row lights when every one is in.
|
|
143
|
+
function NodeRow({ node, depth, doc, state, session, notices, movingFamily, readOnly, prefabTreeOf, folded, onFold, off, upgrades }: INodeRowProps) {
|
|
144
|
+
const movingId = state.movingNodeId;
|
|
145
|
+
const children = node.children ?? [];
|
|
146
|
+
const inDefaultSlot = (child: IBlueprintNodeDoc) => !child.slot || child.slot === DEFAULT_SLOT;
|
|
147
|
+
const sections = new Map<string, IBlueprintNodeDoc[]>();
|
|
148
|
+
for (const child of children) {
|
|
149
|
+
const slotName = inDefaultSlot(child) ? DEFAULT_SLOT : child.slot!;
|
|
150
|
+
sections.set(slotName, [...(sections.get(slotName) ?? []), child]);
|
|
151
|
+
}
|
|
152
|
+
// EMPTY slots show too: the document only knows slots with children, the running page's
|
|
153
|
+
// declarations (fed by the canvas) cover the rest
|
|
154
|
+
for (const declared of state.declaredSlots) {
|
|
155
|
+
if (declared.parentId === node.id && !sections.has(declared.slot)) sections.set(declared.slot, []);
|
|
156
|
+
}
|
|
157
|
+
// a SELECTED slot must not vanish with its children (a cut empties it, the paste puts them
|
|
158
|
+
// back INTO it) — an empty section is synthesized for it
|
|
159
|
+
const selSlot = state.selectedSlot;
|
|
160
|
+
if (selSlot && selSlot.parentId === node.id && !sections.has(selSlot.slot)) {
|
|
161
|
+
sections.set(selSlot.slot, []);
|
|
162
|
+
}
|
|
163
|
+
// the SAME language as the canvas: blue = the ordinary selection, yellow = the Ctrl mode.
|
|
164
|
+
// Pickedness is per element (a flat set): a row is lit iff ITS node is picked
|
|
165
|
+
const inMulti = state.multiSelectedIds.includes(node.id);
|
|
166
|
+
// …and the inspector's hover echoes here as a PALE wash — pointing at a block in any canvas
|
|
167
|
+
// marks its row, quieter than a selection (the selection keeps the louder colour)
|
|
168
|
+
const isHovered = state.hoveredNodeId === node.id && state.selectedNodeId !== node.id;
|
|
169
|
+
const slotRow = (slotName: string, kids: IBlueprintNodeDoc[], base: number) => {
|
|
170
|
+
const allPicked = kids.length > 0 && kids.every((kid) => state.multiSelectedIds.includes(kid.id));
|
|
171
|
+
const isSelected = selSlot?.parentId === node.id && selSlot.slot === slotName;
|
|
172
|
+
return (
|
|
173
|
+
<div className={`tree-view__slot${allPicked ? ' tree-view__slot--multi' : ''}${isSelected ? ' tree-view__slot--selected' : ''}`}
|
|
174
|
+
data-test-id="tree-slot-row"
|
|
175
|
+
data-tooltip="Select this slot — a paste lands inside it · Ctrl+click picks every block in it"
|
|
176
|
+
data-slot={slotName}
|
|
177
|
+
data-slot-of={node.id}
|
|
178
|
+
data-selected={isSelected || undefined}
|
|
179
|
+
data-multi-selected={allPicked || undefined}
|
|
180
|
+
style={{ paddingLeft: `${8 + (base + 1) * 14}px` }}
|
|
181
|
+
onClick={(e) => {
|
|
182
|
+
e.stopPropagation();
|
|
183
|
+
// in the move mode a click anywhere but a tip means "never mind"
|
|
184
|
+
if (movingId) {
|
|
185
|
+
session.cancelMove();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// the slot is a DOOR: Ctrl passes the pick to its children; a plain click makes
|
|
189
|
+
// the slot itself the (blue) selection — copy/cut its children, paste inside
|
|
190
|
+
if (e.ctrlKey || e.metaKey) session.toggleMultiSelectSlot(node.id, slotName);
|
|
191
|
+
else session.selectSlot(node.id, slotName);
|
|
192
|
+
}}>
|
|
193
|
+
#{slotName}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* One legal landing place, standing IN the gap between rows: the zone is slim in the flow but
|
|
199
|
+
* its hit area reaches halfway into both neighbouring rows (negative margins), so "hovering
|
|
200
|
+
* between the centres of two rows" is literal. The tip inside shows on zone hover; clicking
|
|
201
|
+
* it commits the move.
|
|
202
|
+
*/
|
|
203
|
+
const moveZone = (slotName: string, index: number, zoneDepth: number) => {
|
|
204
|
+
if (!movingId || movingFamily.has(node.id)) return null;
|
|
205
|
+
// the two positions around the travelling node itself are the same place it already holds
|
|
206
|
+
const kids = sections.get(slotName) ?? [];
|
|
207
|
+
const pos = kids.findIndex((kid) => kid.id === movingId);
|
|
208
|
+
if (pos !== -1 && (index === pos || index === pos + 1)) return null;
|
|
209
|
+
return (
|
|
210
|
+
// the skewer's LEFT end starts at the landing depth: the line itself says how deep
|
|
211
|
+
// the traveller will sit
|
|
212
|
+
<div className="tree-view__move-zone"
|
|
213
|
+
data-test-id="tree-move-zone"
|
|
214
|
+
data-move-parent={node.id}
|
|
215
|
+
data-move-slot={slotName}
|
|
216
|
+
data-move-index={index}
|
|
217
|
+
style={{ '--skewer-left': `${8 + zoneDepth * 14}px` } as CSSProperties}
|
|
218
|
+
onClick={(e) => {
|
|
219
|
+
e.stopPropagation(); session.cancelMove(); // beside the tip means "never mind"
|
|
220
|
+
}}>
|
|
221
|
+
<span className="tree-view__move-tip"
|
|
222
|
+
data-test-id="tree-move-tip"
|
|
223
|
+
onClick={(e) => {
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
session.commitMove(node.id, slotName, index);
|
|
226
|
+
notices?.show('move', blocksLabel('Moved', 1));
|
|
227
|
+
}}>
|
|
228
|
+
move here
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
// the node's own blocks, slot by slot — under the node, or where a prefab's outlet puts them
|
|
234
|
+
const body = (base: number) => [...sections].map(([slot, slotChildren]) => (
|
|
235
|
+
<Fragment key={slot}>
|
|
236
|
+
{slotRow(slot, slotChildren, base)}
|
|
237
|
+
{slotChildren.map((c, index) => (
|
|
238
|
+
<Fragment key={c.id}>
|
|
239
|
+
{moveZone(slot, index, base + 2)}
|
|
240
|
+
<NodeRow node={c}
|
|
241
|
+
depth={base + 2}
|
|
242
|
+
doc={doc}
|
|
243
|
+
state={state}
|
|
244
|
+
session={session}
|
|
245
|
+
notices={notices}
|
|
246
|
+
movingFamily={movingFamily}
|
|
247
|
+
readOnly={readOnly}
|
|
248
|
+
prefabTreeOf={prefabTreeOf}
|
|
249
|
+
folded={folded}
|
|
250
|
+
onFold={onFold}
|
|
251
|
+
off={off}
|
|
252
|
+
upgrades={upgrades} />
|
|
253
|
+
</Fragment>
|
|
254
|
+
))}
|
|
255
|
+
{moveZone(slot, slotChildren.length, base + 2)}
|
|
256
|
+
</Fragment>
|
|
257
|
+
));
|
|
258
|
+
const prefab = prefabTreeOf(node.reference);
|
|
259
|
+
// a branch folds when there is something under the row: children, declared slots, a prefab's
|
|
260
|
+
// blocks. The root never folds — a page folded to one line says nothing
|
|
261
|
+
const foldable = node.id !== doc.id && (sections.size > 0 || prefab !== null);
|
|
262
|
+
const isFolded = foldable && folded.has(node.id);
|
|
263
|
+
const disconnected = node.id === doc.id ? null : disconnectedOf(node.reference, off);
|
|
264
|
+
const upgrade = upgrades.get(node.id);
|
|
265
|
+
return (
|
|
266
|
+
<>
|
|
267
|
+
<div className={`tree-view__node${state.selectedNodeId === node.id ? ' tree-view__node--selected' : ''}${inMulti ? ' tree-view__node--multi' : ''}${isHovered ? ' tree-view__node--hovered' : ''}${movingFamily.has(node.id) ? ' tree-view__node--moving' : ''}${disconnected ? ' tree-view__node--off' : ''}`}
|
|
268
|
+
data-test-id="tree-node-row"
|
|
269
|
+
data-node-id={node.id}
|
|
270
|
+
data-disconnected={disconnected ?? undefined}
|
|
271
|
+
data-selected={state.selectedNodeId === node.id || undefined}
|
|
272
|
+
data-multi-selected={inMulti || undefined}
|
|
273
|
+
data-hovered={isHovered || undefined}
|
|
274
|
+
data-moving={movingFamily.has(node.id) || undefined}
|
|
275
|
+
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
276
|
+
onClick={(e) => {
|
|
277
|
+
e.stopPropagation();
|
|
278
|
+
if (movingId) {
|
|
279
|
+
session.cancelMove();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (e.ctrlKey || e.metaKey) session.toggleMultiSelect(node.id);
|
|
283
|
+
else session.selectNode(node.id);
|
|
284
|
+
}}>
|
|
285
|
+
{foldable ? (
|
|
286
|
+
<button type="button"
|
|
287
|
+
className="tree-view__fold"
|
|
288
|
+
data-test-id="tree-fold-button"
|
|
289
|
+
data-folded={isFolded || undefined}
|
|
290
|
+
aria-label={isFolded ? 'Unfold this block' : 'Fold this block'}
|
|
291
|
+
data-tooltip={isFolded ? 'Show what is inside this block' : 'Hide what is inside this block'}
|
|
292
|
+
onClick={(e) => {
|
|
293
|
+
e.stopPropagation();
|
|
294
|
+
onFold(node.id);
|
|
295
|
+
}}>
|
|
296
|
+
{FoldIcon}
|
|
297
|
+
</button>
|
|
298
|
+
) : <span className="tree-view__fold tree-view__fold--none"
|
|
299
|
+
aria-hidden="true" />}
|
|
300
|
+
{/* the root reads like every other row — by its block, not by its id: a prefab's root is
|
|
301
|
+
its preview frame (its blocks go where the prefab is placed), and a node id there
|
|
302
|
+
(`cta-frame`) looked like a block that does not exist */}
|
|
303
|
+
<span className="tree-view__label"
|
|
304
|
+
data-tooltip={node.id === doc.id && state.kind === 'prefabs'
|
|
305
|
+
? 'The prefab’s frame: it previews the blocks here; on a page, only the blocks are placed'
|
|
306
|
+
: undefined}>
|
|
307
|
+
{node.id === doc.id ? `${shortName(node.reference)} (root)` : shortName(node.reference)}
|
|
308
|
+
</span>
|
|
309
|
+
{upgrade ? (
|
|
310
|
+
<span className="tree-view__up"
|
|
311
|
+
data-test-id="tree-outdated"
|
|
312
|
+
data-version={upgrade.toVersion}
|
|
313
|
+
data-tooltip={`${upgrade.fromVersion} here — ${upgrade.toVersion} is out: update it in its settings, or all at once from Updates`}>
|
|
314
|
+
↑ {upgrade.toVersion}
|
|
315
|
+
</span>
|
|
316
|
+
) : null}
|
|
317
|
+
{disconnected ? (
|
|
318
|
+
<span className="tree-view__off"
|
|
319
|
+
data-test-id="tree-disconnected"
|
|
320
|
+
data-tooltip="Its tile palette is disconnected from this project: the site does not show this block">
|
|
321
|
+
off
|
|
322
|
+
</span>
|
|
323
|
+
) : null}
|
|
324
|
+
{node.id !== doc.id && !movingId && !readOnly && (
|
|
325
|
+
<span className="tree-view__row-actions">
|
|
326
|
+
<button type="button"
|
|
327
|
+
className="tree-view__move"
|
|
328
|
+
data-test-id="tree-move-button"
|
|
329
|
+
data-action="move"
|
|
330
|
+
data-tooltip={'Move this block — then click "move here" where it should go'}
|
|
331
|
+
onClick={(e) => {
|
|
332
|
+
e.stopPropagation(); session.startMove(node.id);
|
|
333
|
+
}}>
|
|
334
|
+
<svg viewBox="0 0 16 16"
|
|
335
|
+
aria-hidden="true">
|
|
336
|
+
<path d="M8 2.5v11M2.5 8h11M6 4.5L8 2.5l2 2M6 11.5L8 13.5l2-2M4.5 6L2.5 8l2 2M11.5 6L13.5 8l-2 2"
|
|
337
|
+
fill="none"
|
|
338
|
+
stroke="currentColor"
|
|
339
|
+
strokeWidth="1.4" />
|
|
340
|
+
</svg>
|
|
341
|
+
</button>
|
|
342
|
+
<button type="button"
|
|
343
|
+
className="tree-view__remove"
|
|
344
|
+
data-test-id="tree-remove-button"
|
|
345
|
+
data-action="remove"
|
|
346
|
+
data-tooltip="Remove this block with everything inside it (undo brings it back)"
|
|
347
|
+
onClick={(e) => {
|
|
348
|
+
e.stopPropagation(); session.removeNode(node.id);
|
|
349
|
+
}}>
|
|
350
|
+
✕
|
|
351
|
+
</button>
|
|
352
|
+
</span>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
{isFolded ? null : prefab ? (
|
|
356
|
+
<>
|
|
357
|
+
<PrefabRows nodes={prefab.doc.children}
|
|
358
|
+
depth={depth + 1}
|
|
359
|
+
owner={node.id}
|
|
360
|
+
state={state}
|
|
361
|
+
session={session}
|
|
362
|
+
body={body} />
|
|
363
|
+
{/* no outlet: the prefab's blocks, then the node's own */}
|
|
364
|
+
{hasOutlet(prefab.doc.children) ? null : body(depth)}
|
|
365
|
+
</>
|
|
366
|
+
) : body(depth)}
|
|
367
|
+
</>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ONE component, live in the bubble too: the specimen renders against a real session that the
|
|
372
|
+
// editor facet's previewServices seed with a small page (previewEnvironment.ts) — the panel
|
|
373
|
+
// itself never knows it is being looked at.
|
|
374
|
+
function TreeView() {
|
|
375
|
+
const { session, state } = useEditingSession();
|
|
376
|
+
const notices = noticesOf(useDi(DI_CONTAINER_TOKEN));
|
|
377
|
+
const ctrlHeld = useCtrlHeld();
|
|
378
|
+
// the blocks of disconnected distributions stay in the document; their rows say the site leaves them out
|
|
379
|
+
const off = useDisabledDistributions();
|
|
380
|
+
// …and a block with a newer version says so on its row (ADR-0061)
|
|
381
|
+
const { outdated } = useUpgrades(state.doc, !useIsPreviewMode());
|
|
382
|
+
const upgrades = useMemo(() => new Map(outdated.map((node) => [node.nodeId, node.upgrade])), [outdated]);
|
|
383
|
+
// the tree mirrors the ACTIVE canvas's document — its tab says which one (optional: no dock,
|
|
384
|
+
// no tab to name)
|
|
385
|
+
const dockTab = useDiOptional(DOCK_TAB_TOKEN);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
const named = state.kind === 'prefabs' ? `prefab ${state.pageName}` : state.pageName;
|
|
388
|
+
dockTab?.rename(state.pageName ? `Tree · ${named}` : 'Tree');
|
|
389
|
+
}, [dockTab, state.kind, state.pageName]);
|
|
390
|
+
// the project's prefabs, to show what a prefab node brings (a specimen asks no server)
|
|
391
|
+
const project = useProjectPrefabs(!useIsPreviewMode());
|
|
392
|
+
// the tree FOLLOWS a selection made elsewhere (a block picked in the canvas): its row comes to
|
|
393
|
+
// the middle of the panel, as far as the panel scrolls. Scrolling only — the tree never selects
|
|
394
|
+
// in answer, so a pick here and the canvas following it cannot chase each other. A row clicked
|
|
395
|
+
// HERE stays where it is: it would slide out from under the pointer
|
|
396
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
397
|
+
const clickedHere = useRef<string | null>(null);
|
|
398
|
+
// the branches folded away — kept across the documents this tree shows, keyed by the page
|
|
399
|
+
const [folded, setFolded] = useState<ReadonlySet<string>>(() => new Set());
|
|
400
|
+
const foldKey = (nodeId: string): string => `${state.kind}/${state.pageName}/${nodeId}`;
|
|
401
|
+
const foldedHere = useMemo(() => {
|
|
402
|
+
const prefix = `${state.kind}/${state.pageName}/`;
|
|
403
|
+
return new Set([...folded].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length)));
|
|
404
|
+
}, [folded, state.kind, state.pageName]);
|
|
405
|
+
const toggleFold = (nodeId: string): void => setFolded((prev) => {
|
|
406
|
+
const next = new Set(prev);
|
|
407
|
+
const key = foldKey(nodeId);
|
|
408
|
+
if (next.has(key)) next.delete(key);
|
|
409
|
+
else next.add(key);
|
|
410
|
+
return next;
|
|
411
|
+
});
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
const id = state.selectedNodeId;
|
|
414
|
+
const root = rootRef.current;
|
|
415
|
+
const here = clickedHere.current === id;
|
|
416
|
+
clickedHere.current = null;
|
|
417
|
+
if (!id || !root || here) return;
|
|
418
|
+
// a selection made elsewhere unfolds its way: every folded ancestor opens — for a prefab's
|
|
419
|
+
// block, its prefab node too, which folds the prefab's rows away
|
|
420
|
+
const owner = unitOwnerOf(id);
|
|
421
|
+
if (state.doc) {
|
|
422
|
+
const ancestors = owner === null ? ancestorsOf(state.doc, id) : [...ancestorsOf(state.doc, owner), owner];
|
|
423
|
+
if (ancestors.some((ancestor) => foldedHere.has(ancestor))) {
|
|
424
|
+
setFolded((prev) => {
|
|
425
|
+
const next = new Set(prev);
|
|
426
|
+
for (const ancestor of ancestors) next.delete(foldKey(ancestor));
|
|
427
|
+
return next;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const row = [...root.querySelectorAll('[data-node-id], [data-part-id]')]
|
|
432
|
+
.find((el) => (el.getAttribute('data-node-id') ?? el.getAttribute('data-part-id')) === id);
|
|
433
|
+
// not every DOM scrolls (a test's) — the tree then just does not move
|
|
434
|
+
row?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
|
|
435
|
+
}, [state.selectedNodeId]);
|
|
436
|
+
|
|
437
|
+
if (!state.doc) {
|
|
438
|
+
return <div className="tree-view tree-view__empty"
|
|
439
|
+
data-tree-empty>Open a page draft (Pages panel)</div>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const doc = state.doc;
|
|
443
|
+
// a published revision is immutable (the session refuses every edit): the tree still selects,
|
|
444
|
+
// but offers no move and no removal
|
|
445
|
+
const readOnly = state.revision !== null;
|
|
446
|
+
// The MOVE mode (its own exclusive mode — isolation keeps modes from breeding emergent bugs):
|
|
447
|
+
// one node is travelling, rows stop being hover/click targets, and the GAPS between them take
|
|
448
|
+
// over — a purple "move here" tip per legal position. Gaps inside the travelling branch (a
|
|
449
|
+
// cycle) and the two positions around the node itself (no-ops) offer nothing.
|
|
450
|
+
const movingId = state.movingNodeId;
|
|
451
|
+
const movingFamily = new Set<string>();
|
|
452
|
+
if (movingId) {
|
|
453
|
+
const branch = findNodeDoc(doc, movingId);
|
|
454
|
+
(function collect(n: IBlueprintNodeDoc | null) {
|
|
455
|
+
if (!n) return;
|
|
456
|
+
movingFamily.add(n.id);
|
|
457
|
+
for (const c of n.children ?? []) collect(c);
|
|
458
|
+
})(branch);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<TooltipHost>
|
|
463
|
+
{/* the container knows the modifier so a plain :hover can wear the mode's colour — with Ctrl
|
|
464
|
+
down, pointing at a row promises the yellow pick, exactly like hovering in the canvas */}
|
|
465
|
+
<div ref={rootRef}
|
|
466
|
+
// which row a click here lands on — that selection is the tree's own, nothing to follow
|
|
467
|
+
onMouseDownCapture={(e) => {
|
|
468
|
+
const row = (e.target as Element).closest?.('[data-node-id], [data-part-id]');
|
|
469
|
+
clickedHere.current = row?.getAttribute('data-node-id') ?? row?.getAttribute('data-part-id') ?? null;
|
|
470
|
+
}}
|
|
471
|
+
className={`tree-view${ctrlHeld ? ' tree-view--ctrl' : ''}${movingId ? ' tree-view--move' : ''}`}
|
|
472
|
+
data-test-id="tree"
|
|
473
|
+
data-tree
|
|
474
|
+
data-tree-moving={movingId ?? undefined}
|
|
475
|
+
data-tree-readonly={readOnly || undefined}>
|
|
476
|
+
{/* no save button: the draft saves itself after a lull, and a button that is disabled
|
|
477
|
+
whenever there is nothing to save only ever says what the dot already says */}
|
|
478
|
+
<div className="tree-view__header">
|
|
479
|
+
<span data-edited-page={state.pageName}
|
|
480
|
+
data-edited-kind={state.kind}>
|
|
481
|
+
{state.kind === 'prefabs' ? 'prefab ' : ''}{state.pageName} · {readOnly ? `r${state.revision}` : (state.doc as { title?: string }).title ?? state.draftId?.slice(0, 8)}
|
|
482
|
+
</span>
|
|
483
|
+
{state.dirty && <span className="tree-view__dirty"
|
|
484
|
+
data-dirty
|
|
485
|
+
data-tooltip="Unsaved changes — the draft saves itself after a pause">●</span>}
|
|
486
|
+
</div>
|
|
487
|
+
{state.saveFailure && (
|
|
488
|
+
<div className="tree-view__error"
|
|
489
|
+
data-save-error={state.saveFailure}>{state.saveFailure}</div>
|
|
490
|
+
)}
|
|
491
|
+
<NodeRow node={doc}
|
|
492
|
+
depth={0}
|
|
493
|
+
doc={doc}
|
|
494
|
+
state={state}
|
|
495
|
+
session={session}
|
|
496
|
+
notices={notices}
|
|
497
|
+
movingFamily={movingFamily}
|
|
498
|
+
readOnly={readOnly}
|
|
499
|
+
prefabTreeOf={project.treeOf}
|
|
500
|
+
folded={foldedHere}
|
|
501
|
+
onFold={toggleFold}
|
|
502
|
+
off={off}
|
|
503
|
+
upgrades={upgrades} />
|
|
504
|
+
</div>
|
|
505
|
+
</TooltipHost>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export default markInternalTile(defineComponentSide({
|
|
510
|
+
Component: TreeView,
|
|
511
|
+
defaultSettings() {
|
|
512
|
+
return {};
|
|
513
|
+
},
|
|
514
|
+
}));
|