@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,271 @@
1
+ // THE DISTRIBUTIONS the project's blocks come from (ADR-0059): a card each — its cover, icon,
2
+ // title, what it brings and where its source lives — and, opened, its blocks as a wall of LIVE
3
+ // previews in the manner of Pinterest: each card shows the block as its editor facet declares it
4
+ // (the same `preview` the picker's bubble shows), mounted only once the card comes into sight.
5
+ // A block's card opens the block's own page (blockPage.tsx, ADR-0060): its playground and variants.
6
+ //
7
+ // The workbench's list page shows this at `/workbench/distributions`; the distribution opened is
8
+ // the address's `?palette=`, the block `&block=` — shallow moves, Back goes back up.
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+ import { useServerAction } from '@mosetta/react/server-action';
11
+ import { useComponent } from '@mosetta/react';
12
+ import { compareVersions } from '@mosetta/content';
13
+ import type { ICatalogEntry, IDistributionEntry } from '@mosetta/content';
14
+ import { listComponentsToken } from './contentCommon';
15
+ import { useDisabledDistributions } from './disconnected';
16
+ import { usePreviewPlan } from './componentPreview';
17
+ import { STAGE_WIDTH, useScaledStage, useSeen } from './scaledStage';
18
+ import { BlockPage } from './blockPage';
19
+ import { catalogVersions } from './catalogVersions';
20
+ import './distributions.css';
21
+
22
+ const NO_ENTRIES: readonly ICatalogEntry[] = [];
23
+ const NO_DISTRIBUTIONS: readonly IDistributionEntry[] = [];
24
+
25
+ /** the address parameters naming the tile palette opened, its version, and the block */
26
+ export const DISTRIBUTION_PARAM = 'palette';
27
+ export const VERSION_PARAM = 'version';
28
+ export const BLOCK_PARAM = 'block';
29
+
30
+ interface IOpened {
31
+ readonly distribution: string | null;
32
+ /** a version other than the latest, or null — the latest */
33
+ readonly version?: string | null;
34
+ readonly block: string | null;
35
+ }
36
+
37
+ const readOpened = (): IOpened => {
38
+ if (typeof window === 'undefined') return { distribution: null, version: null, block: null };
39
+ const search = new URLSearchParams(window.location.search);
40
+ return { distribution: search.get(DISTRIBUTION_PARAM), version: search.get(VERSION_PARAM), block: search.get(BLOCK_PARAM) };
41
+ };
42
+
43
+ /** what is opened, kept in the address — Back and a shared link find it again */
44
+ function useOpened(): [IOpened, (next: IOpened) => void] {
45
+ const [opened, setOpened] = useState<IOpened>(readOpened);
46
+ useEffect(() => {
47
+ const follow = () => setOpened(readOpened());
48
+ window.addEventListener('popstate', follow);
49
+ return () => window.removeEventListener('popstate', follow);
50
+ }, []);
51
+ const open = (next: IOpened) => {
52
+ const url = new URL(window.location.href);
53
+ for (const [param, value] of [[DISTRIBUTION_PARAM, next.distribution], [VERSION_PARAM, next.version ?? null], [BLOCK_PARAM, next.block]] as const) {
54
+ if (value) url.searchParams.set(param, value);
55
+ else url.searchParams.delete(param);
56
+ }
57
+ window.history.pushState(window.history.state, '', url);
58
+ setOpened(next);
59
+ };
60
+ return [opened, open];
61
+ }
62
+
63
+ /** the blocks a distribution offers authors — no page roots, no tools' own */
64
+ const blocksOf = (components: readonly ICatalogEntry[], d: IDistributionEntry) => components
65
+ .filter((c) => c.reference.namespace === d.namespace && c.reference.version === d.version && !c.isRoot && !c.internal)
66
+ .sort((a, b) => a.reference.name.localeCompare(b.reference.name));
67
+
68
+ /** a link to the source, quiet, never the card's own click */
69
+ function SourceLink({ href }: { href: string }) {
70
+ return (
71
+ <a className="distributions__source"
72
+ href={href}
73
+ target="_blank"
74
+ rel="noreferrer"
75
+ data-test-id="distribution-source"
76
+ onClick={(e) => e.stopPropagation()}>Repository ↗</a>
77
+ );
78
+ }
79
+
80
+ function DistributionCard({ distribution, blocks, onOpen }: { distribution: IDistributionEntry; blocks: number; onOpen: () => void }) {
81
+ return (
82
+ <button type="button"
83
+ className="distributions__card"
84
+ data-test-id="distribution-card"
85
+ data-distribution={distribution.namespace}
86
+ onClick={onOpen}>
87
+ <span className="distributions__cover">
88
+ {distribution.coverUrl ? <img src={distribution.coverUrl}
89
+ alt=""
90
+ draggable={false} /> : null}
91
+ </span>
92
+ <span className="distributions__card-body">
93
+ <span className="distributions__card-head">
94
+ {distribution.iconUrl ? <img className="distributions__icon"
95
+ src={distribution.iconUrl}
96
+ alt=""
97
+ draggable={false} /> : null}
98
+ <span className="distributions__title">{distribution.title}</span>
99
+ </span>
100
+ {distribution.description ? <span className="distributions__description">{distribution.description}</span> : null}
101
+ <span className="distributions__meta">
102
+ <span>{`${blocks} ${blocks === 1 ? 'block' : 'blocks'} · ${distribution.namespace}@${distribution.version}`}</span>
103
+ {distribution.source ? <SourceLink href={distribution.source} /> : null}
104
+ </span>
105
+ </span>
106
+ </button>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * One block, shown live: its declared preview mounted once the card is in sight, laid out at a
112
+ * stage width and scaled to the card's. The card opens the block's page.
113
+ */
114
+ function SpecimenCard({ component, libraryRoot, onOpen }: { component: ICatalogEntry; libraryRoot: string | undefined; onOpen: () => void }) {
115
+ const self = useComponent().reference;
116
+ const cardRef = useRef<HTMLDivElement>(null);
117
+ const seen = useSeen(cardRef);
118
+ const [target] = useState(() => ({ reference: component.id, hasEditor: component.hasEditor, libraryRoot, anchor: { top: 0, left: 0, right: 0 } }));
119
+ const plan = usePreviewPlan(seen ? target : null, self);
120
+ const { stage, failed, scale, height } = useScaledStage(cardRef, plan.status === 'ready' ? plan.spec : null);
121
+ const none = plan.status === 'none' || plan.status === 'failed' || (plan.status === 'ready' && failed);
122
+ // a card, not a <button>: the block inside renders buttons and links of its own
123
+ return (
124
+ <div ref={cardRef}
125
+ className="distributions__specimen"
126
+ role="link"
127
+ tabIndex={0}
128
+ data-test-id="distribution-block"
129
+ data-component={component.id}
130
+ data-specimen={plan.status}
131
+ onClick={onOpen}
132
+ onKeyDown={(e) => {
133
+ if (e.key === 'Enter' || e.key === ' ') {
134
+ e.preventDefault();
135
+ onOpen();
136
+ }
137
+ }}>
138
+ <div className="distributions__specimen-head">
139
+ {component.iconUrl ? <img className="distributions__block-icon"
140
+ src={component.iconUrl}
141
+ alt=""
142
+ draggable={false} /> : null}
143
+ <span className="distributions__block-name">{component.reference.name}</span>
144
+ <span className="distributions__specimen-open"
145
+ aria-hidden="true">Open ›</span>
146
+ </div>
147
+ <div className="distributions__specimen-view"
148
+ style={{ height: none ? undefined : `${Math.max(48, height)}px` }}>
149
+ {none ? <div className="distributions__no-preview">No preview declared</div> : null}
150
+ <div ref={stage}
151
+ className="distributions__stage"
152
+ style={{ width: `${STAGE_WIDTH}px`, transform: `scale(${scale})` }} />
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ export function DistributionsView() {
159
+ const catalog = useServerAction({ token: listComponentsToken, input: {} });
160
+ const [opened, open] = useOpened();
161
+ const disabled = useDisabledDistributions();
162
+ const components: readonly ICatalogEntry[] = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.components : NO_ENTRIES;
163
+ const every: readonly IDistributionEntry[] = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.distributions ?? NO_DISTRIBUTIONS : NO_DISTRIBUTIONS;
164
+ // every version deployed stays in the catalog (ADR-0061): a card per distribution, at its latest
165
+ const versions = useMemo(() => catalogVersions(components, every), [components, every]);
166
+ const latestOf = (d: IDistributionEntry) => compareVersions(d.version, versions.latest(d.namespace) ?? d.version) === 0;
167
+ // a disconnected distribution is not this site's any more: no card
168
+ const distributions = every
169
+ .filter((d) => !d.internal && !disabled.includes(d.namespace) && latestOf(d) && blocksOf(components, d).length > 0);
170
+ const libraryRootOf = (d: IDistributionEntry) => components.find((c) => c.isRoot
171
+ && c.reference.namespace === d.namespace && c.reference.version === d.version)?.id;
172
+
173
+ if (catalog.isLoading()) {
174
+ return <div className="distributions distributions--note">Loading the tile palettes…</div>;
175
+ }
176
+ if (catalog.isError() || (catalog.isSuccess() && !catalog.serverData.ok)) {
177
+ return <div className="distributions distributions--note">Could not read the tile palettes</div>;
178
+ }
179
+ // the distribution opened — at the version the address names, or its latest
180
+ const card = opened.distribution ? distributions.find((d) => d.namespace === opened.distribution) : undefined;
181
+ const shown = card && opened.version ? versions.distribution(card.namespace, opened.version) ?? card : card;
182
+ // the latest is the address without a version
183
+ const atVersion = (version: string) => (card && compareVersions(version, card.version) !== 0 ? version : null);
184
+ const block = shown && opened.block ? blocksOf(components, shown).find((c) => c.reference.name === opened.block) : undefined;
185
+ if (shown && block) {
186
+ return (
187
+ <BlockPage key={block.id}
188
+ component={block}
189
+ distribution={shown}
190
+ components={components}
191
+ libraryRoot={libraryRootOf(shown)}
192
+ onBack={() => open({ distribution: shown.namespace, version: atVersion(shown.version), block: null })} />
193
+ );
194
+ }
195
+ if (shown) {
196
+ const blocks = blocksOf(components, shown);
197
+ const deployed = versions.versions(shown.namespace);
198
+ return (
199
+ <div className="distributions"
200
+ data-test-id="distributions"
201
+ data-opened={shown.namespace}>
202
+ <div className="distributions__header">
203
+ <button type="button"
204
+ className="distributions__back"
205
+ data-test-id="distributions-back"
206
+ onClick={() => open({ distribution: null, block: null })}>← All tile palettes</button>
207
+ <div className="distributions__header-main">
208
+ {shown.iconUrl ? <img className="distributions__icon distributions__icon--large"
209
+ src={shown.iconUrl}
210
+ alt=""
211
+ draggable={false} /> : null}
212
+ <div>
213
+ <h1 className="distributions__heading">{shown.title}</h1>
214
+ {shown.description ? <p className="distributions__lead">{shown.description}</p> : null}
215
+ <p className="distributions__meta">
216
+ <span>{`${blocks.length} ${blocks.length === 1 ? 'block' : 'blocks'} · ${shown.namespace}`}</span>
217
+ {deployed.length > 1 ? (
218
+ <select className="distributions__version"
219
+ data-test-id="distribution-version"
220
+ aria-label={`The version of ${shown.title} to show`}
221
+ value={shown.version}
222
+ onChange={(e) => open({ distribution: shown.namespace, version: atVersion(e.target.value), block: null })}>
223
+ {deployed.map((version, i) => (
224
+ <option key={version}
225
+ value={version}>
226
+ {i === 0 ? `${version} · latest` : version}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ ) : <span>{shown.version}</span>}
231
+ {shown.source ? <SourceLink href={shown.source} /> : null}
232
+ </p>
233
+ {shown.changes ? (
234
+ <details className="distributions__changes"
235
+ data-test-id="distribution-changes">
236
+ <summary>{`What changed in ${shown.version}`}</summary>
237
+ <pre>{shown.changes}</pre>
238
+ </details>
239
+ ) : null}
240
+ </div>
241
+ </div>
242
+ </div>
243
+ <div className="distributions__wall">
244
+ {blocks.map((c) => (
245
+ <SpecimenCard key={c.id}
246
+ component={c}
247
+ libraryRoot={libraryRootOf(shown)}
248
+ onOpen={() => open({ distribution: shown.namespace, version: atVersion(shown.version), block: c.reference.name })} />
249
+ ))}
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+ return (
255
+ <div className="distributions"
256
+ data-test-id="distributions">
257
+ <div className="distributions__header">
258
+ <h1 className="distributions__heading">Tile palettes</h1>
259
+ <p className="distributions__lead">Where this site's blocks come from — open one to see its blocks.</p>
260
+ </div>
261
+ <div className="distributions__cards">
262
+ {distributions.map((d) => (
263
+ <DistributionCard key={d.id}
264
+ distribution={d}
265
+ blocks={blocksOf(components, d).length}
266
+ onOpen={() => open({ distribution: d.namespace, block: null })} />
267
+ ))}
268
+ </div>
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,125 @@
1
+ // Keeping the DRAFT ON DISK close to the session — a service of the workbench, not of a panel.
2
+ //
3
+ // It used to be an effect inside the tree panel, which made persistence a property of a piece of
4
+ // UI: closing that tab took the layout's panel out of the tree, unmounted the module, and the
5
+ // draft quietly stopped being written. The session is shared through DI precisely because it
6
+ // belongs to no panel — whoever renders first brings it into being and everyone else pokes at it —
7
+ // and what keeps it on disk belongs at the same level.
8
+ //
9
+ // Written against the DI surface of server actions rather than the react hook: a mutation handle
10
+ // (`createManual`) is a plain subscribe/snapshot pair, and cache invalidation rides along exactly
11
+ // as it does for a panel's own mutation.
12
+ import { DiContainer, SingleDiToken } from '@mosetta/di';
13
+ import { ContentKind, EditingSession, IPageDoc } from '@mosetta/content';
14
+ import { fullServerTileId } from '@mosetta/server-action/core';
15
+ import {
16
+ ensureServerActionClient,
17
+ IServerDataHandle,
18
+ SERVER_DATA_MANAGER,
19
+ } from '@mosetta/server-action/client';
20
+ import type { ITileReference } from '@mosetta/core';
21
+ import { ContentActionResult, savePageDraftToken } from './contentCommon';
22
+
23
+ /** how long the session has to stay still before the draft is worth writing */
24
+ const LULL_MS = 1000;
25
+
26
+ type TSaveInput = { name: string; draftId: string; page: IPageDoc; kind?: ContentKind };
27
+ type TSaveOutput = ContentActionResult<{ warnings: string[] }>;
28
+
29
+ export class EditingAutosave {
30
+ private timer: ReturnType<typeof setTimeout> | null = null;
31
+
32
+ /** the document the request in flight is carrying — only IT can be called saved when it lands */
33
+ private sent: IPageDoc | null = null;
34
+
35
+ /** the seq the in-flight write was stamped with — what the session's chain advances to */
36
+ private sentSeq = 0;
37
+
38
+ /**
39
+ * The SESSION the in-flight write belongs to, captured as its bound settle pair. The service
40
+ * holds the hub's ACTIVE facade, which routes at property-access time — so a canvas switch
41
+ * while the request is in the air must not land the answer (and the seq advance) in the other
42
+ * canvas's session.
43
+ */
44
+ private sentSettle: { markSaved: EditingSession['markSaved']; markSaveFailed: EditingSession['markSaveFailed'] } | null = null;
45
+
46
+ constructor(
47
+ private readonly session: EditingSession,
48
+ private readonly handle: IServerDataHandle<TSaveOutput, TSaveInput>,
49
+ ) {
50
+ this.session.subscribe(this.onSessionChanged);
51
+ this.handle.subscribe(this.onSaveSettled);
52
+ }
53
+
54
+ /**
55
+ * Every change re-arms the timer, so a burst of keystrokes costs ONE write, a second after the
56
+ * last of them. A save already in flight is left alone: whatever came after it keeps the session
57
+ * dirty, and the landing re-arms.
58
+ */
59
+ private readonly onSessionChanged = (): void => {
60
+ const state = this.session.snapshot();
61
+ if (!state.dirty || !state.doc || this.sent) return;
62
+ // a broken write chain (someone else saved this draft) cannot be retried into submission —
63
+ // saving stays parked until the conflict is resolved (fork or rollback re-opens the page)
64
+ if (state.saveFailure === 'CONFLICT') return;
65
+ if (this.timer !== null) clearTimeout(this.timer);
66
+ this.timer = setTimeout(this.save, LULL_MS);
67
+ };
68
+
69
+ private readonly save = (): void => {
70
+ this.timer = null;
71
+ const { doc, kind, pageName, draftId, seq, dirty } = this.session.snapshot();
72
+ if (!dirty || !doc || pageName === null || draftId === null) return;
73
+ this.sent = doc;
74
+ // the wire copy carries the NEXT link of the draft's write chain; the working doc itself is
75
+ // untouched (its identity is how markSaved knows what landed). A refused chain = CONFLICT.
76
+ this.sentSeq = seq + 1;
77
+ // capturing the bound pair NOW pins the answer to the session that sent the request
78
+ this.sentSettle = {
79
+ markSaved: this.session.markSaved.bind(this.session),
80
+ markSaveFailed: this.session.markSaveFailed.bind(this.session),
81
+ };
82
+ this.handle.snapshot().start!({ name: pageName, draftId, page: { ...doc, seq: this.sentSeq }, kind });
83
+ };
84
+
85
+ private readonly onSaveSettled = (): void => {
86
+ const data = this.handle.snapshot();
87
+ if (data.isLoading() || !this.sent || !this.sentSettle) return;
88
+ const sent = this.sent;
89
+ const settle = this.sentSettle;
90
+ this.sent = null;
91
+ this.sentSettle = null;
92
+ if (data.isError()) settle.markSaveFailed(data.serverError.code);
93
+ else if (data.isSuccess() && !data.serverData.ok) settle.markSaveFailed(data.serverData.code);
94
+ // …and only the document that was actually written counts as saved: the session may well have
95
+ // moved on while the request was in the air
96
+ else if (data.isSuccess()) settle.markSaved(sent, this.sentSeq);
97
+ // whatever the session holds now is either newer than what landed or was never written —
98
+ // either way the next write is scheduled by the same rule as any other change
99
+ this.onSessionChanged();
100
+ };
101
+ }
102
+
103
+ export const EDITING_AUTOSAVE_TOKEN = new SingleDiToken<EditingAutosave>('EDITING_AUTOSAVE_TOKEN');
104
+
105
+ /**
106
+ * Bring the autosave into being on first use, the way the session itself is brought into being:
107
+ * any widget that touches the session arms it, and none of them owns it. `reference` is the
108
+ * caller's own module name — the namespace and version of the server module live there, which is
109
+ * the one thing about a server action that a container cannot know by itself.
110
+ */
111
+ export function ensureEditingAutosave(di: DiContainer, session: EditingSession, reference: ITileReference): void {
112
+ if (di.hasByToken(EDITING_AUTOSAVE_TOKEN)) return;
113
+ ensureServerActionClient(di);
114
+ const manager = di.getByToken(SERVER_DATA_MANAGER);
115
+ const handle = manager.createManual<TSaveInput, TSaveOutput>({
116
+ fullId: fullServerTileId({
117
+ namespace: reference.namespace,
118
+ version: reference.version,
119
+ name: savePageDraftToken.serverModuleName ?? reference.name,
120
+ }),
121
+ actionName: savePageDraftToken.actionName,
122
+ invalidates: savePageDraftToken.invalidates ?? [],
123
+ });
124
+ di.registerValueOfToken(EDITING_AUTOSAVE_TOKEN, new EditingAutosave(session, handle));
125
+ }
@@ -0,0 +1,185 @@
1
+ // Copy/paste of tree structure THROUGH THE OS CLIPBOARD — which is what makes it work between
2
+ // tabs (and between pages): what travels is a text/plain JSON envelope with a marker, so a paste
3
+ // recognises its own kind and leaves anybody else's text alone.
4
+ //
5
+ // Two doors, one meaning. The keyboard goes through the NATIVE copy/paste events — their
6
+ // clipboardData is synchronous and needs no permission; the toolbar buttons cannot raise those,
7
+ // so they go through navigator.clipboard (see canvas). Both end in the same session methods:
8
+ // copySelection() builds the picked forest, pasteNodes() inserts it as ONE undoable step.
9
+ //
10
+ // A service of the workbench, brought into being the way the session, the autosave and the
11
+ // hotkeys are — and armed only in the real workbench (a palette specimen shares the window).
12
+ import { DiContainer, SingleDiToken } from '@mosetta/di';
13
+ import { CANONICAL_REFERENCE_RE, EditingSession, IBlueprintNodeDoc } from '@mosetta/content';
14
+ import { PREVIEW_TYPE_TOKEN } from '@mosetta/runtime/client';
15
+ import { fieldOwnsEvent } from './editingHotkeys';
16
+ import { blocksLabel, EditingNotices, noticesOf } from './editingNotices';
17
+ import { vocabularyFromDoc, vocabularyOf } from './blockVocabulary';
18
+ import { blocksFromText, hasText } from './pasteText';
19
+ import { isVariantSource } from './blockVariants';
20
+
21
+ const CLIPBOARD_MARK = 'mosetta:blocks';
22
+
23
+ /** guards against depth bombs — ours AND every recursive walk downstream of a paste */
24
+ const MAX_BLOCK_DEPTH = 64;
25
+
26
+ /** …and against sheer size: nobody pastes two thousand blocks on purpose */
27
+ const MAX_BLOCK_NODES = 2000;
28
+
29
+ /** settings keys that would land as magic properties in naive consumers — never let them in */
30
+ const POISON_KEYS = ['__proto__', 'constructor', 'prototype'];
31
+
32
+ export function serializeBlocks(blocks: readonly IBlueprintNodeDoc[]): string {
33
+ return JSON.stringify({ mosetta: CLIPBOARD_MARK, version: 1, blocks });
34
+ }
35
+
36
+ /**
37
+ * Rebuild one arriving node from its KNOWN fields alone, or refuse it. The clipboard is a TRUST
38
+ * BOUNDARY — any program can write it, and the marker only says "shaped like ours", not "sane".
39
+ * The invariant this enforces: whatever a paste admits must also pass the server's zod on the
40
+ * next autosave — a paste that bricks the saving of the draft is worse than no paste at all.
41
+ * So: canonical reference, typed fields, junk keys dropped, depth and node budgets enforced.
42
+ */
43
+ function sanitizeNode(node: unknown, depth: number, budget: { left: number }): IBlueprintNodeDoc | null {
44
+ if (depth > MAX_BLOCK_DEPTH || budget.left <= 0) return null;
45
+ budget.left -= 1;
46
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) return null;
47
+ const shape = node as {
48
+ id?: unknown; reference?: unknown; children?: unknown;
49
+ slot?: unknown; settings?: unknown; ssr?: unknown;
50
+ };
51
+ if (typeof shape.id !== 'string' || !shape.id) return null;
52
+ if (typeof shape.reference !== 'string' || !CANONICAL_REFERENCE_RE.test(shape.reference)) return null;
53
+ if (!Array.isArray(shape.children)) return null;
54
+ if (shape.slot !== undefined && (typeof shape.slot !== 'string' || !shape.slot)) return null;
55
+ if (shape.settings !== undefined
56
+ && (typeof shape.settings !== 'object' || shape.settings === null || Array.isArray(shape.settings))) return null;
57
+ if (shape.ssr !== undefined && typeof shape.ssr !== 'boolean') return null;
58
+ const children: IBlueprintNodeDoc[] = [];
59
+ for (const child of shape.children) {
60
+ const clean = sanitizeNode(child, depth + 1, budget);
61
+ if (!clean) return null;
62
+ children.push(clean);
63
+ }
64
+ const settings = shape.settings === undefined
65
+ ? undefined
66
+ : Object.fromEntries(Object.entries(shape.settings).filter(([key]) => !POISON_KEYS.includes(key)));
67
+ return {
68
+ id: shape.id,
69
+ reference: shape.reference,
70
+ children,
71
+ ...(shape.slot !== undefined ? { slot: shape.slot } : {}),
72
+ ...(settings !== undefined ? { settings } : {}),
73
+ ...(shape.ssr !== undefined ? { ssr: shape.ssr } : {}),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The blocks inside a clipboard text, or null for anything that is not our envelope — or IS our
79
+ * envelope with anything rotten inside: one bad node refuses the whole paste (all or nothing;
80
+ * half a structure appearing on the page would be the confusing outcome).
81
+ */
82
+ export function parseBlocks(text: string): IBlueprintNodeDoc[] | null {
83
+ try {
84
+ const envelope = JSON.parse(text) as { mosetta?: unknown; blocks?: unknown };
85
+ if (envelope?.mosetta !== CLIPBOARD_MARK || !Array.isArray(envelope.blocks) || !envelope.blocks.length) {
86
+ return null;
87
+ }
88
+ const budget = { left: MAX_BLOCK_NODES };
89
+ const blocks: IBlueprintNodeDoc[] = [];
90
+ for (const raw of envelope.blocks) {
91
+ const clean = sanitizeNode(raw, 1, budget);
92
+ if (!clean) return null;
93
+ blocks.push(clean);
94
+ }
95
+ return blocks;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export class EditingClipboard {
102
+ constructor(
103
+ private readonly session: EditingSession,
104
+ private readonly notices: EditingNotices | null,
105
+ private readonly di: DiContainer | null = null,
106
+ ) {
107
+ window.addEventListener('copy', this.onCopy);
108
+ window.addEventListener('cut', this.onCut);
109
+ window.addEventListener('paste', this.onPaste);
110
+ }
111
+
112
+ /** nothing of ours to copy → the native copy proceeds untouched (text selection stays usable) */
113
+ private readonly onCopy = (event: ClipboardEvent): void => {
114
+ if (fieldOwnsEvent(event.target)) return;
115
+ const blocks = this.session.copySelection();
116
+ if (!blocks.length || !event.clipboardData) return;
117
+ event.preventDefault();
118
+ event.clipboardData.setData('text/plain', serializeBlocks(blocks));
119
+ this.notices?.show('copy', blocksLabel('Copied', blocks.length));
120
+ };
121
+
122
+ /** a cut is the same envelope — written FIRST, then the picked regions leave the page whole */
123
+ private readonly onCut = (event: ClipboardEvent): void => {
124
+ if (fieldOwnsEvent(event.target)) return;
125
+ const blocks = this.session.copySelection();
126
+ if (!blocks.length || !event.clipboardData) return;
127
+ event.preventDefault();
128
+ event.clipboardData.setData('text/plain', serializeBlocks(blocks));
129
+ this.session.removeSelection();
130
+ this.notices?.show('cut', blocksLabel('Cut', blocks.length));
131
+ };
132
+
133
+ /**
134
+ * Two kinds of paste, one door: our envelope lands as the blocks it carries; any other TEXT
135
+ * becomes blocks by the writing splitter (paragraphs, headings, lists, quotes) — the way an
136
+ * article written elsewhere enters the page. A field keeps its own paste (text goes INTO it).
137
+ */
138
+ private readonly onPaste = (event: ClipboardEvent): void => {
139
+ if (fieldOwnsEvent(event.target)) return;
140
+ const text = event.clipboardData?.getData('text/plain') ?? '';
141
+ const blocks = text ? parseBlocks(text) : null;
142
+ if (blocks) {
143
+ event.preventDefault();
144
+ this.session.pasteNodes(blocks);
145
+ this.notices?.show('paste', blocksLabel('Pasted', blocks.length));
146
+ return;
147
+ }
148
+ if (!hasText(text) || !this.session.snapshot().doc) return;
149
+ // a block's VARIANT (its page's "Copy as JSON") is for an editor facet, not words for a page:
150
+ // say where the block itself is copied instead of pasting a paragraph of braces
151
+ if (isVariantSource(text)) {
152
+ event.preventDefault();
153
+ this.notices?.show('refuse', 'That is a block’s variant as JSON, for its .editor.tsx — “Copy block” copies the block itself');
154
+ return;
155
+ }
156
+ const vocab = this.textVocabulary();
157
+ if (!vocab) {
158
+ // nothing to build with: say so instead of silently swallowing a paste
159
+ this.notices?.show('paste', 'Open the Palette once — then text pastes as blocks');
160
+ return;
161
+ }
162
+ const built = blocksFromText(text, vocab);
163
+ if (!built.length) return;
164
+ event.preventDefault();
165
+ this.session.pasteNodes(built);
166
+ this.notices?.show('paste', `${blocksLabel('Pasted', built.length)} from text`);
167
+ };
168
+
169
+ /** the catalog's word first (the palette registers it), the page's own blocks otherwise */
170
+ private textVocabulary() {
171
+ const fromDi = this.di ? vocabularyOf(this.di) : null;
172
+ if (fromDi) return fromDi;
173
+ const doc = this.session.snapshot().doc;
174
+ return doc ? vocabularyFromDoc(doc) : null;
175
+ }
176
+ }
177
+
178
+ export const EDITING_CLIPBOARD_TOKEN = new SingleDiToken<EditingClipboard>('EDITING_CLIPBOARD_TOKEN');
179
+
180
+ export function ensureEditingClipboard(di: DiContainer, session: EditingSession): void {
181
+ if (typeof window === 'undefined') return; // ssr render has no clipboard to listen for
182
+ if (di.hasByToken(EDITING_CLIPBOARD_TOKEN)) return;
183
+ if (di.hasByToken(PREVIEW_TYPE_TOKEN) && di.getByToken(PREVIEW_TYPE_TOKEN) !== null) return;
184
+ di.registerValueOfToken(EDITING_CLIPBOARD_TOKEN, new EditingClipboard(session, noticesOf(di), di));
185
+ }