@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,223 @@
|
|
|
1
|
+
// The CONFLICT interceptor: when a save is refused because the draft's write chain moved on
|
|
2
|
+
// (someone else saved this draft — code CONFLICT, 412 at the trust boundary), the autosave parks
|
|
3
|
+
// itself and this service raises a modal over the workbench: FORK the local copy into a fresh
|
|
4
|
+
// draft, or ROLL BACK to what the store holds. A service of the workbench like the autosave and
|
|
5
|
+
// the notices — owned by no panel, plain DOM on the body, armed only in the real workbench.
|
|
6
|
+
import { DiContainer, SingleDiToken } from '@mosetta/di';
|
|
7
|
+
import { ContentKind, EditingSession, ILoadedDraft, IPageDoc } from '@mosetta/content';
|
|
8
|
+
import { PREVIEW_TYPE_TOKEN } from '@mosetta/runtime/client';
|
|
9
|
+
import { fullServerTileId } from '@mosetta/server-action/core';
|
|
10
|
+
import {
|
|
11
|
+
ensureServerActionClient,
|
|
12
|
+
IServerDataHandle,
|
|
13
|
+
SERVER_DATA_MANAGER,
|
|
14
|
+
} from '@mosetta/server-action/client';
|
|
15
|
+
import type { ITileReference } from '@mosetta/core';
|
|
16
|
+
import { ContentActionResult, forkPageDraftToken, loadPageDraftToken } from './contentCommon';
|
|
17
|
+
import { EditingNotices, noticesOf } from './editingNotices';
|
|
18
|
+
|
|
19
|
+
type TForkInput = { name: string; page: IPageDoc; kind?: ContentKind };
|
|
20
|
+
type TForkOutput = ContentActionResult<{ draftId: string; page: IPageDoc }>;
|
|
21
|
+
type TReloadInput = { name: string; draftId: string; kind?: ContentKind };
|
|
22
|
+
type TReloadOutput = ContentActionResult<{ draft: ILoadedDraft<IPageDoc> }>;
|
|
23
|
+
|
|
24
|
+
const STYLE = `
|
|
25
|
+
.mosetta-conflict { position: fixed; inset: 0; z-index: 2147483090; display: flex; align-items: center;
|
|
26
|
+
justify-content: center; background: rgb(24 24 27 / 45%); }
|
|
27
|
+
.mosetta-conflict__card { width: 380px; max-width: calc(100vw - 48px); padding: 20px; border-radius: 8px;
|
|
28
|
+
background: #ffffff; color: #18181b; font: 13px/1.55 ui-sans-serif, system-ui, sans-serif;
|
|
29
|
+
box-shadow: 0 12px 32px rgb(0 0 0 / 25%); }
|
|
30
|
+
.mosetta-conflict__title { margin: 0 0 8px; font-size: 14px; font-weight: 600; }
|
|
31
|
+
.mosetta-conflict__text { margin: 0 0 16px; color: #3f3f46; }
|
|
32
|
+
.mosetta-conflict__actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
33
|
+
.mosetta-conflict__button { padding: 6px 12px; border: 1px solid #d4d4d8; border-radius: 6px;
|
|
34
|
+
background: #ffffff; color: #18181b; font: inherit; cursor: pointer; }
|
|
35
|
+
.mosetta-conflict__button:hover { background: #f4f4f5; }
|
|
36
|
+
.mosetta-conflict__button--fork { border-color: #2563eb; background: #2563eb; color: #ffffff; }
|
|
37
|
+
.mosetta-conflict__button--fork:hover { background: #1d4ed8; }
|
|
38
|
+
.mosetta-conflict__error { margin: 12px 0 0; color: #dc2626; }
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export class EditingConflicts {
|
|
42
|
+
private overlay: HTMLElement | null = null;
|
|
43
|
+
|
|
44
|
+
private errorLine: HTMLElement | null = null;
|
|
45
|
+
|
|
46
|
+
private forkInFlight = false;
|
|
47
|
+
|
|
48
|
+
private reloadInFlight = false;
|
|
49
|
+
|
|
50
|
+
/** the SESSION the in-flight resolution belongs to — the facade routes at access time, and a
|
|
51
|
+
* canvas switch mid-request must not open the result into the other canvas's session */
|
|
52
|
+
private resolveInto: { openPage: EditingSession['openPage']; pageName: string; kind: ContentKind } | null = null;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly session: EditingSession,
|
|
56
|
+
private readonly fork: IServerDataHandle<TForkOutput, TForkInput>,
|
|
57
|
+
private readonly reload: IServerDataHandle<TReloadOutput, TReloadInput>,
|
|
58
|
+
private readonly notices: EditingNotices | null,
|
|
59
|
+
) {
|
|
60
|
+
this.session.subscribe(this.onSessionChanged);
|
|
61
|
+
this.fork.subscribe(this.onForkSettled);
|
|
62
|
+
this.reload.subscribe(this.onReloadSettled);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private readonly onSessionChanged = (): void => {
|
|
66
|
+
const state = this.session.snapshot();
|
|
67
|
+
if (state.saveFailure === 'CONFLICT' && state.doc && state.pageName !== null && state.draftId !== null) {
|
|
68
|
+
this.show();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// any resolution clears the failure (openPage resets it) — the modal must follow
|
|
72
|
+
if (state.saveFailure !== 'CONFLICT') this.hide();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
private readonly onFork = (): void => {
|
|
76
|
+
if (this.forkInFlight) return;
|
|
77
|
+
const { kind, pageName, doc } = this.session.snapshot();
|
|
78
|
+
if (pageName === null || !doc) return;
|
|
79
|
+
this.forkInFlight = true;
|
|
80
|
+
this.resolveInto = { openPage: this.session.openPage.bind(this.session), pageName, kind };
|
|
81
|
+
this.fork.snapshot().start!({ name: pageName, page: doc, kind });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
private readonly onRollback = (): void => {
|
|
85
|
+
if (this.reloadInFlight) return;
|
|
86
|
+
const { kind, pageName, draftId } = this.session.snapshot();
|
|
87
|
+
if (pageName === null || draftId === null) return;
|
|
88
|
+
this.reloadInFlight = true;
|
|
89
|
+
this.resolveInto = { openPage: this.session.openPage.bind(this.session), pageName, kind };
|
|
90
|
+
this.reload.snapshot().start!({ name: pageName, draftId, kind });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
private readonly onForkSettled = (): void => {
|
|
94
|
+
const data = this.fork.snapshot();
|
|
95
|
+
if (data.isLoading() || !this.forkInFlight) return;
|
|
96
|
+
this.forkInFlight = false;
|
|
97
|
+
if (data.isSuccess()) {
|
|
98
|
+
if (!data.serverData.ok) {
|
|
99
|
+
this.showError(data.serverData.code);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (this.resolveInto) {
|
|
103
|
+
const { draftId, page } = data.serverData;
|
|
104
|
+
// the local copy lives on under its fresh identity; opening it clears the failure → hides
|
|
105
|
+
this.resolveInto.openPage(this.resolveInto.pageName, draftId, page, this.resolveInto.kind);
|
|
106
|
+
this.notices?.show('fork', 'Forked into your own draft');
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (data.isError()) this.showError(data.serverError.code);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
private readonly onReloadSettled = (): void => {
|
|
114
|
+
const data = this.reload.snapshot();
|
|
115
|
+
if (data.isLoading() || !this.reloadInFlight) return;
|
|
116
|
+
this.reloadInFlight = false;
|
|
117
|
+
if (data.isSuccess()) {
|
|
118
|
+
if (!data.serverData.ok) {
|
|
119
|
+
// the draft may be GONE (someone published or discarded it) — nothing to roll back onto
|
|
120
|
+
this.showError(data.serverData.code === 'NO_DRAFT'
|
|
121
|
+
? 'the draft is gone from the store — fork your copy instead'
|
|
122
|
+
: data.serverData.code);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.resolveInto) {
|
|
126
|
+
const { draft } = data.serverData;
|
|
127
|
+
this.resolveInto.openPage(draft.name, draft.id, draft.doc, this.resolveInto.kind);
|
|
128
|
+
this.notices?.show('undo', 'Rolled back to the saved draft');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (data.isError()) this.showError(data.serverError.code);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
private show(): void {
|
|
136
|
+
if (this.overlay?.isConnected) return;
|
|
137
|
+
const style = document.createElement('style');
|
|
138
|
+
style.textContent = STYLE;
|
|
139
|
+
document.head.appendChild(style);
|
|
140
|
+
|
|
141
|
+
this.overlay = document.createElement('div');
|
|
142
|
+
this.overlay.className = 'mosetta-conflict';
|
|
143
|
+
this.overlay.setAttribute('data-test-id', 'conflict-modal');
|
|
144
|
+
|
|
145
|
+
const card = document.createElement('div');
|
|
146
|
+
card.className = 'mosetta-conflict__card';
|
|
147
|
+
const title = document.createElement('p');
|
|
148
|
+
title.className = 'mosetta-conflict__title';
|
|
149
|
+
title.textContent = 'This draft was saved by someone else';
|
|
150
|
+
const text = document.createElement('p');
|
|
151
|
+
text.className = 'mosetta-conflict__text';
|
|
152
|
+
text.textContent = 'Your copy and the stored draft have diverged. Fork to keep working on your own copy, '
|
|
153
|
+
+ 'or roll back to the stored draft — your unsaved changes will be lost.';
|
|
154
|
+
|
|
155
|
+
const actions = document.createElement('div');
|
|
156
|
+
actions.className = 'mosetta-conflict__actions';
|
|
157
|
+
const rollback = document.createElement('button');
|
|
158
|
+
rollback.type = 'button';
|
|
159
|
+
rollback.className = 'mosetta-conflict__button';
|
|
160
|
+
rollback.setAttribute('data-test-id', 'conflict-rollback-button');
|
|
161
|
+
rollback.textContent = 'Roll back my changes';
|
|
162
|
+
rollback.addEventListener('click', this.onRollback);
|
|
163
|
+
const forkButton = document.createElement('button');
|
|
164
|
+
forkButton.type = 'button';
|
|
165
|
+
forkButton.className = 'mosetta-conflict__button mosetta-conflict__button--fork';
|
|
166
|
+
forkButton.setAttribute('data-test-id', 'conflict-fork-button');
|
|
167
|
+
forkButton.textContent = 'Fork my copy';
|
|
168
|
+
forkButton.addEventListener('click', this.onFork);
|
|
169
|
+
actions.append(rollback, forkButton);
|
|
170
|
+
|
|
171
|
+
this.errorLine = document.createElement('p');
|
|
172
|
+
this.errorLine.className = 'mosetta-conflict__error';
|
|
173
|
+
this.errorLine.setAttribute('data-test-id', 'conflict-error');
|
|
174
|
+
this.errorLine.style.display = 'none';
|
|
175
|
+
|
|
176
|
+
card.append(title, text, actions, this.errorLine);
|
|
177
|
+
this.overlay.appendChild(card);
|
|
178
|
+
document.body.appendChild(this.overlay);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private showError(message: string): void {
|
|
182
|
+
if (!this.errorLine) return;
|
|
183
|
+
this.errorLine.textContent = message;
|
|
184
|
+
this.errorLine.style.display = 'block';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private hide(): void {
|
|
188
|
+
this.overlay?.remove();
|
|
189
|
+
this.overlay = null;
|
|
190
|
+
this.errorLine = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const EDITING_CONFLICTS_TOKEN = new SingleDiToken<EditingConflicts>('EDITING_CONFLICTS_TOKEN');
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Armed the way the autosave is (whichever widget touches the session first), and only in the
|
|
198
|
+
* real workbench: a palette specimen never saves, so it can never conflict — but its window is
|
|
199
|
+
* the editor's, and a modal from a demo would cover the real work.
|
|
200
|
+
*/
|
|
201
|
+
export function ensureEditingConflicts(di: DiContainer, session: EditingSession, reference: ITileReference): void {
|
|
202
|
+
if (typeof window === 'undefined') return;
|
|
203
|
+
if (di.hasByToken(EDITING_CONFLICTS_TOKEN)) return;
|
|
204
|
+
if (di.hasByToken(PREVIEW_TYPE_TOKEN) && di.getByToken(PREVIEW_TYPE_TOKEN) !== null) return;
|
|
205
|
+
ensureServerActionClient(di);
|
|
206
|
+
const manager = di.getByToken(SERVER_DATA_MANAGER);
|
|
207
|
+
const moduleOf = (name: string | undefined) => fullServerTileId({
|
|
208
|
+
namespace: reference.namespace,
|
|
209
|
+
version: reference.version,
|
|
210
|
+
name: name ?? reference.name,
|
|
211
|
+
});
|
|
212
|
+
const fork = manager.createManual<TForkInput, TForkOutput>({
|
|
213
|
+
fullId: moduleOf(forkPageDraftToken.serverModuleName),
|
|
214
|
+
actionName: forkPageDraftToken.actionName,
|
|
215
|
+
invalidates: forkPageDraftToken.invalidates ?? [],
|
|
216
|
+
});
|
|
217
|
+
const reload = manager.createManual<TReloadInput, TReloadOutput>({
|
|
218
|
+
fullId: moduleOf(loadPageDraftToken.serverModuleName),
|
|
219
|
+
actionName: loadPageDraftToken.actionName,
|
|
220
|
+
invalidates: loadPageDraftToken.invalidates ?? [],
|
|
221
|
+
});
|
|
222
|
+
di.registerValueOfToken(EDITING_CONFLICTS_TOKEN, new EditingConflicts(session, fork, reload, noticesOf(di)));
|
|
223
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Ctrl+Z / Ctrl+Shift+Z (and their Cmd twins) for the editing session — a service of the
|
|
2
|
+
// workbench, brought into being the way the session and the autosave are: by whichever widget
|
|
3
|
+
// touches the session first, owned by none of them (a closed panel takes nothing with it).
|
|
4
|
+
//
|
|
5
|
+
// Armed ONLY in the real workbench (PREVIEW_TYPE null): a palette specimen runs its own session
|
|
6
|
+
// in the same window, and a listener per specimen would undo in every demo at once.
|
|
7
|
+
import { DiContainer, SingleDiToken } from '@mosetta/di';
|
|
8
|
+
import { EditingSession } from '@mosetta/content';
|
|
9
|
+
import { PREVIEW_TYPE_TOKEN } from '@mosetta/runtime/client';
|
|
10
|
+
import { EditingNotices, noticesOf } from './editingNotices';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the event was born inside a form control or anything contenteditable — such a field
|
|
14
|
+
* keeps its own native undo/copy/paste, and the session never hears about it. Duck-typed on
|
|
15
|
+
* purpose: `instanceof HTMLElement` names one realm's class, and an element from another realm
|
|
16
|
+
* is no instance of it.
|
|
17
|
+
*/
|
|
18
|
+
export function fieldOwnsEvent(target: unknown): boolean {
|
|
19
|
+
const element = target as { tagName?: unknown; isContentEditable?: unknown } | null;
|
|
20
|
+
if (element?.isContentEditable === true) return true;
|
|
21
|
+
return typeof element?.tagName === 'string' && /^(input|textarea|select)$/i.test(element.tagName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class EditingHotkeys {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly session: EditingSession,
|
|
27
|
+
private readonly notices: EditingNotices | null,
|
|
28
|
+
) {
|
|
29
|
+
window.addEventListener('keydown', this.onKeyDown);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
|
33
|
+
if (fieldOwnsEvent(event.target)) return;
|
|
34
|
+
// Escape leaves the move mode — the one mode with nothing else to leave it by keyboard
|
|
35
|
+
if (event.key === 'Escape' && this.session.snapshot().movingNodeId !== null) {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
this.session.cancelMove();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
// a notice only for a step that HAPPENED — an empty stack stays silent
|
|
43
|
+
const before = this.session.snapshot().doc;
|
|
44
|
+
if (event.shiftKey) this.session.redo();
|
|
45
|
+
else this.session.undo();
|
|
46
|
+
if (this.session.snapshot().doc === before) return;
|
|
47
|
+
if (event.shiftKey) this.notices?.show('redo', 'Redone');
|
|
48
|
+
else this.notices?.show('undo', 'Undone');
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const EDITING_HOTKEYS_TOKEN = new SingleDiToken<EditingHotkeys>('EDITING_HOTKEYS_TOKEN');
|
|
53
|
+
|
|
54
|
+
export function ensureEditingHotkeys(di: DiContainer, session: EditingSession): void {
|
|
55
|
+
if (typeof window === 'undefined') return; // ssr render has no keys to listen for
|
|
56
|
+
if (di.hasByToken(EDITING_HOTKEYS_TOKEN)) return;
|
|
57
|
+
if (di.hasByToken(PREVIEW_TYPE_TOKEN) && di.getByToken(PREVIEW_TYPE_TOKEN) !== null) return;
|
|
58
|
+
di.registerValueOfToken(EDITING_HOTKEYS_TOKEN, new EditingHotkeys(session, noticesOf(di)));
|
|
59
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Transient NOTICES in the top-right corner — icon plus a line of text — for the gestures whose
|
|
2
|
+
// outcome is otherwise invisible at the pointer: copy/cut/paste and undo/redo. A service of the
|
|
3
|
+
// workbench like the autosave and the hotkeys, and deliberately PLAIN DOM: no panel may own the
|
|
4
|
+
// layer (panels close), and a service that draws its own fixed container on the body needs no
|
|
5
|
+
// react host and no module-scoped css — one injected style tag, prefixed class names.
|
|
6
|
+
import { DiContainer, SingleDiToken } from '@mosetta/di';
|
|
7
|
+
import { PREVIEW_TYPE_TOKEN } from '@mosetta/runtime/client';
|
|
8
|
+
|
|
9
|
+
export type TNoticeKind = 'copy' | 'cut' | 'paste' | 'undo' | 'redo' | 'move' | 'fork' | 'link' | 'publish' | 'refuse' | 'delete';
|
|
10
|
+
|
|
11
|
+
/** how long a notice stands before it fades */
|
|
12
|
+
const NOTICE_MS = 2400;
|
|
13
|
+
|
|
14
|
+
const STROKE = 'fill="none" stroke="currentColor" stroke-width="1.6"';
|
|
15
|
+
|
|
16
|
+
// the same glyphs the canvas bar wears — one visual language for the action and its receipt
|
|
17
|
+
const ICONS: Record<TNoticeKind, string> = {
|
|
18
|
+
copy: `<svg viewBox="0 0 16 16"><rect x="5.6" y="5.6" width="7.6" height="7.6" rx="1.4" ${STROKE}/>`
|
|
19
|
+
+ `<path d="M3 10.4V4.4A1.6 1.6 0 0 1 4.6 2.8h6" ${STROKE}/></svg>`,
|
|
20
|
+
cut: `<svg viewBox="0 0 16 16"><circle cx="4" cy="4.2" r="2" ${STROKE}/><circle cx="4" cy="11.8" r="2" ${STROKE}/>`
|
|
21
|
+
+ `<path d="M13.4 2.6L5.6 10.7M5.6 5.3l3.2 3.3M9.9 9.6l3.5 3.8" ${STROKE}/></svg>`,
|
|
22
|
+
paste: '<svg viewBox="0 0 16 16"><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.4'
|
|
23
|
+
+ `A1.4 1.4 0 0 1 3 13.3v-9A1.4 1.4 0 0 1 4.4 2.9h1.2" ${STROKE}/>`
|
|
24
|
+
+ `<rect x="5.5" y="1.4" width="5" height="2.9" rx="0.7" ${STROKE}/></svg>`,
|
|
25
|
+
undo: `<svg viewBox="0 0 16 16"><path d="M2 4.7v4h4" ${STROKE}/><path d="M14 11.3a6 6 0 0 0-10-4.4L2 8.7" ${STROKE}/></svg>`,
|
|
26
|
+
redo: `<svg viewBox="0 0 16 16"><path d="M14 4.7v4h-4" ${STROKE}/><path d="M2 11.3a6 6 0 0 1 10-4.4L14 8.7" ${STROKE}/></svg>`,
|
|
27
|
+
move: `<svg viewBox="0 0 16 16"><path d="M8 2.5v11M2.5 8h11" ${STROKE}/>`
|
|
28
|
+
+ `<path d="M6 4.5L8 2.5l2 2M6 11.5L8 13.5l2-2M4.5 6L2.5 8l2 2M11.5 6L13.5 8l-2 2" ${STROKE}/></svg>`,
|
|
29
|
+
fork: `<svg viewBox="0 0 16 16"><circle cx="4" cy="3.5" r="1.8" ${STROKE}/><circle cx="12" cy="3.5" r="1.8" ${STROKE}/>`
|
|
30
|
+
+ `<circle cx="8" cy="12.5" r="1.8" ${STROKE}/><path d="M4 5.3v1.2a3 3 0 0 0 3 3h2a3 3 0 0 0 3-3V5.3M8 9.5v1.2" ${STROKE}/></svg>`,
|
|
31
|
+
link: `<svg viewBox="0 0 16 16"><path d="M6.8 9.2a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-1 1" ${STROKE}/>`
|
|
32
|
+
+ `<path d="M9.2 6.8a3 3 0 0 0-4.2 0l-2 2a3 3 0 0 0 4.2 4.2l1-1" ${STROKE}/></svg>`,
|
|
33
|
+
publish: `<svg viewBox="0 0 16 16"><path d="M8 12.6V4M4.6 7.2L8 3.6l3.4 3.6" ${STROKE}/><path d="M3 13.6h10" ${STROKE}/></svg>`,
|
|
34
|
+
refuse: `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.6" ${STROKE}/><path d="M4.4 4.4l7.2 7.2" ${STROKE}/></svg>`,
|
|
35
|
+
delete: `<svg viewBox="0 0 16 16"><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.7 8.5h5.6l.7-8.5M6.7 7v3.7M9.3 7v3.7" ${STROKE}/></svg>`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const STYLE = `
|
|
39
|
+
.mosetta-notices { position: fixed; top: 12px; right: 12px; z-index: 2147483100; display: flex;
|
|
40
|
+
flex-direction: column; align-items: flex-end; gap: 8px; pointer-events: none; }
|
|
41
|
+
.mosetta-notice { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px;
|
|
42
|
+
background: #27272a; color: #ffffff; font: 13px/18px ui-sans-serif, system-ui, sans-serif;
|
|
43
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%); opacity: 0; transform: translateY(-6px);
|
|
44
|
+
transition: opacity 0.2s ease, transform 0.2s ease; }
|
|
45
|
+
.mosetta-notice--shown { opacity: 1; transform: translateY(0); }
|
|
46
|
+
.mosetta-notice__icon { display: flex; }
|
|
47
|
+
.mosetta-notice__icon svg { width: 14px; height: 14px; display: block; }
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/** "Copied 2 blocks" and its kin — the counts every clipboard notice speaks in */
|
|
51
|
+
export function blocksLabel(verb: string, count: number): string {
|
|
52
|
+
return `${verb} ${count} block${count === 1 ? '' : 's'}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class EditingNotices {
|
|
56
|
+
private container: HTMLElement | null = null;
|
|
57
|
+
|
|
58
|
+
show(kind: TNoticeKind, text: string): void {
|
|
59
|
+
const toast = document.createElement('div');
|
|
60
|
+
toast.className = 'mosetta-notice';
|
|
61
|
+
toast.setAttribute('data-test-id', 'notice');
|
|
62
|
+
toast.setAttribute('data-notice-kind', kind);
|
|
63
|
+
const icon = document.createElement('span');
|
|
64
|
+
icon.className = 'mosetta-notice__icon';
|
|
65
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
66
|
+
icon.innerHTML = ICONS[kind];
|
|
67
|
+
const label = document.createElement('span');
|
|
68
|
+
label.textContent = text;
|
|
69
|
+
toast.append(icon, label);
|
|
70
|
+
this.host().appendChild(toast);
|
|
71
|
+
// the shown class lands a frame later, so the slide-in transition has a state to start from.
|
|
72
|
+
// The WINDOW's rAF, not the bare global: this service runs wherever the workbench realm is
|
|
73
|
+
// mounted, and a bare name resolves against the module's own realm (node in the jsdom e2e)
|
|
74
|
+
window.requestAnimationFrame(() => toast.classList.add('mosetta-notice--shown'));
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
toast.classList.remove('mosetta-notice--shown');
|
|
77
|
+
setTimeout(() => toast.remove(), 300);
|
|
78
|
+
}, NOTICE_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private host(): HTMLElement {
|
|
82
|
+
if (this.container?.isConnected) return this.container;
|
|
83
|
+
const style = document.createElement('style');
|
|
84
|
+
style.textContent = STYLE;
|
|
85
|
+
document.head.appendChild(style);
|
|
86
|
+
this.container = document.createElement('div');
|
|
87
|
+
this.container.className = 'mosetta-notices';
|
|
88
|
+
this.container.setAttribute('data-test-id', 'notices');
|
|
89
|
+
document.body.appendChild(this.container);
|
|
90
|
+
return this.container;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const EDITING_NOTICES_TOKEN = new SingleDiToken<EditingNotices>('EDITING_NOTICES_TOKEN');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The workbench's notices, or null where they must stay silent: server renders, and preview
|
|
98
|
+
* containers — a palette specimen performing its demo gestures must not toast over the editor.
|
|
99
|
+
*/
|
|
100
|
+
export function noticesOf(di: DiContainer): EditingNotices | null {
|
|
101
|
+
if (typeof window === 'undefined') return null;
|
|
102
|
+
if (di.hasByToken(PREVIEW_TYPE_TOKEN) && di.getByToken(PREVIEW_TYPE_TOKEN) !== null) return null;
|
|
103
|
+
if (!di.hasByToken(EDITING_NOTICES_TOKEN)) di.registerValueOfToken(EDITING_NOTICES_TOKEN, new EditingNotices());
|
|
104
|
+
return di.getByToken(EDITING_NOTICES_TOKEN);
|
|
105
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Which root mounts a block's editor facet: the block's own library ships a root → it renders
|
|
2
|
+
// its own blocks and their forms (a form written in another binding is shown by that binding);
|
|
3
|
+
// otherwise the editor's own page root does. Shared by every host of editor facets — the settings
|
|
4
|
+
// panel's form and a provider's chrome in the canvas corner.
|
|
5
|
+
import { compareVersions, versionOfReference } from '@mosetta/content';
|
|
6
|
+
import type { ICatalogEntry } from '@mosetta/content';
|
|
7
|
+
import type { ITileReference } from '@mosetta/core';
|
|
8
|
+
|
|
9
|
+
const namespaceOf = (reference: string) => /^_ns_(.+?)_t_/.exec(reference)?.[1] ?? '';
|
|
10
|
+
|
|
11
|
+
export function formRootOf(components: readonly ICatalogEntry[], blockReference: string, editor: ITileReference): string {
|
|
12
|
+
// the catalog names a version dotted, an id spells it with underscores: compared as versions
|
|
13
|
+
const version = versionOfReference(blockReference);
|
|
14
|
+
const library = version === null ? undefined : components.find((c) => c.isRoot
|
|
15
|
+
&& c.reference.namespace === namespaceOf(blockReference)
|
|
16
|
+
&& compareVersions(c.reference.version, version) === 0)?.id;
|
|
17
|
+
return library ?? `_ns_${editor.namespace}_t_component_n_page_v_${editor.version}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A forgiving search for short names (blocks, layouts): pure functions, no react.
|
|
2
|
+
//
|
|
3
|
+
// It extends the pages panel's tiers (pagesSearch.matchPath: prefix → initials → substring →
|
|
4
|
+
// scatter) with two kinds of forgiveness people need when typing fast:
|
|
5
|
+
//
|
|
6
|
+
// WRONG KEYBOARD LAYOUT — the term typed with the Russian layout on is read as the Latin keys
|
|
7
|
+
// it was typed on, and the other way round: `руфвштп` finds heading, `йцук` finds qwer.
|
|
8
|
+
// Both spellings are tried; the one as typed wins a tie.
|
|
9
|
+
// TYPOS — when no tier accepts the term, a tier 4 accepts it within a small edit distance of
|
|
10
|
+
// the beginning of some word of the name (optimal string alignment: a swapped pair of
|
|
11
|
+
// neighbours is one edit): `haeding`, `hedaing`, `headng` all find heading. One edit for a
|
|
12
|
+
// term of 3–4 characters, two from 5; none below 3 — a two-letter term within one edit of
|
|
13
|
+
// everything would match everything.
|
|
14
|
+
//
|
|
15
|
+
// Every match carries the indices to highlight, like the tiers it extends.
|
|
16
|
+
import { IPathMatch, matchPath, wordStarts } from '../pagesPanel/pagesSearch';
|
|
17
|
+
|
|
18
|
+
const LATIN = 'qwertyuiop[]asdfghjkl;\'zxcvbnm,./`';
|
|
19
|
+
const CYRILLIC = 'йцукенгшщзхъфывапролджэячсмитьбю.ё';
|
|
20
|
+
|
|
21
|
+
const toLatin = new Map<string, string>();
|
|
22
|
+
const toCyrillic = new Map<string, string>();
|
|
23
|
+
for (let i = 0; i < LATIN.length; i += 1) {
|
|
24
|
+
toLatin.set(CYRILLIC[i], LATIN[i]);
|
|
25
|
+
toCyrillic.set(LATIN[i], CYRILLIC[i]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The same keys read in the other layout: Cyrillic letters become the Latin keys they sit on,
|
|
30
|
+
* Latin keys become the Cyrillic letters. Characters of neither (digits, spaces) stay.
|
|
31
|
+
*/
|
|
32
|
+
export function otherLayout(text: string): string {
|
|
33
|
+
let out = '';
|
|
34
|
+
for (const ch of text.toLowerCase()) {
|
|
35
|
+
// a Cyrillic letter first: '.' is both a Cyrillic key (ю's neighbour) and a Latin one
|
|
36
|
+
out += toLatin.get(ch) ?? toCyrillic.get(ch) ?? ch;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** edits between two strings — insert, delete, substitute, or swap two neighbours */
|
|
42
|
+
export function editDistance(a: string, b: string): number {
|
|
43
|
+
const rows = a.length + 1;
|
|
44
|
+
const cols = b.length + 1;
|
|
45
|
+
const d: number[][] = Array.from({ length: rows }, (_, i) => Array.from({ length: cols }, (__, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
46
|
+
for (let i = 1; i < rows; i += 1) {
|
|
47
|
+
for (let j = 1; j < cols; j += 1) {
|
|
48
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
49
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
50
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
51
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return d[rows - 1][cols - 1];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** how many edits a term of this length may carry */
|
|
59
|
+
function allowedEdits(length: number): number {
|
|
60
|
+
if (length < 3) return 0;
|
|
61
|
+
return length <= 4 ? 1 : 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tier 4: the term, give or take a few edits, is how a word of the name BEGINS — the beginning of
|
|
66
|
+
* the name, or of any word inside it (after a separator, at a camelCase hump).
|
|
67
|
+
*/
|
|
68
|
+
function typoMatch(text: string, term: string): IPathMatch | null {
|
|
69
|
+
const edits = allowedEdits(term.length);
|
|
70
|
+
if (edits === 0) return null;
|
|
71
|
+
const lower = text.toLowerCase();
|
|
72
|
+
let best: { distance: number; start: number; length: number } | null = null;
|
|
73
|
+
for (const start of wordStarts(text)) {
|
|
74
|
+
for (let length = Math.max(1, term.length - edits); length <= term.length + edits; length += 1) {
|
|
75
|
+
if (start + length > lower.length) break;
|
|
76
|
+
const distance = editDistance(term, lower.slice(start, start + length));
|
|
77
|
+
if (distance > edits) continue;
|
|
78
|
+
if (!best || distance < best.distance || (distance === best.distance && start < best.start)) {
|
|
79
|
+
best = { distance, start, length };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!best) return null;
|
|
84
|
+
return {
|
|
85
|
+
tier: 4,
|
|
86
|
+
score: best.distance * 10_000 + best.start * 100 + text.length,
|
|
87
|
+
indices: Array.from({ length: best.length }, (_, i) => best.start + i),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const better = (a: IPathMatch | null, b: IPathMatch | null): IPathMatch | null => {
|
|
92
|
+
if (!a) return b;
|
|
93
|
+
if (!b) return a;
|
|
94
|
+
return b.tier < a.tier || (b.tier === a.tier && b.score < a.score) ? b : a;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** null = no match; otherwise the best match of the term as typed or in the other layout */
|
|
98
|
+
export function matchFuzzy(text: string, term: string): IPathMatch | null {
|
|
99
|
+
const typed = term.trim().toLowerCase();
|
|
100
|
+
if (!typed) return { tier: 0, score: 0, indices: [] };
|
|
101
|
+
const spellings = [typed, otherLayout(typed)].filter((s, i, all) => all.indexOf(s) === i);
|
|
102
|
+
let best: IPathMatch | null = null;
|
|
103
|
+
spellings.forEach((spelling, i) => {
|
|
104
|
+
const match = matchPath(text, spelling) ?? typoMatch(text, spelling);
|
|
105
|
+
// the other layout loses a tie: what was typed is more likely what was meant
|
|
106
|
+
best = better(best, match && i > 0 ? { ...match, score: match.score + 1 } : match);
|
|
107
|
+
});
|
|
108
|
+
return best;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** matches sorted best-first; ties break by score, then alphabetically */
|
|
112
|
+
export function rankFuzzy<T>(items: readonly T[], term: string, textOf: (item: T) => string): Array<{ item: T; match: IPathMatch }> {
|
|
113
|
+
return items
|
|
114
|
+
.flatMap((item) => {
|
|
115
|
+
const match = matchFuzzy(textOf(item), term);
|
|
116
|
+
return match ? [{ item, match }] : [];
|
|
117
|
+
})
|
|
118
|
+
.sort((a, b) => a.match.tier - b.match.tier
|
|
119
|
+
|| a.match.score - b.match.score
|
|
120
|
+
|| textOf(a.item).localeCompare(textOf(b.item)));
|
|
121
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The workbench's uploader: a File from a settings form → the host signs an upload for exactly
|
|
2
|
+
// that file (MEDIA_UPLOAD_PATH: the store's key stays with the host) → the browser PUTs the file
|
|
3
|
+
// straight to the store → the file's public URL. Registered ONCE in the workbench container under
|
|
4
|
+
// MEDIA_UPLOAD_TOKEN; a hosted form (an Image or a Video block's facet) finds it through the
|
|
5
|
+
// parent chain. A video of a hundred megabytes never passes through a server action.
|
|
6
|
+
import { DiContainer } from '@mosetta/di';
|
|
7
|
+
import { MEDIA_UPLOAD_TOKEN } from '@mosetta/react';
|
|
8
|
+
import { MEDIA_UPLOAD_PATH, mediaIssue } from '@mosetta/content/media';
|
|
9
|
+
|
|
10
|
+
interface ISignedUpload {
|
|
11
|
+
readonly upload?: { readonly method: string; readonly url: string; readonly headers: Readonly<Record<string, string>> };
|
|
12
|
+
readonly url?: string;
|
|
13
|
+
readonly error?: { readonly code?: string; readonly message?: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const refusal = (status: number, body: ISignedUpload | null): string => {
|
|
17
|
+
if (body?.error?.message) return body.error.message;
|
|
18
|
+
if (status === 401) return 'Your editing session has ended — reload the page to sign in again (nothing on the page is lost)';
|
|
19
|
+
if (status === 403) return 'You may not upload to this project';
|
|
20
|
+
return `The upload could not start (${status})`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** the PUT itself: XHR, not fetch — a large video needs its progress shown */
|
|
24
|
+
function put(signed: NonNullable<ISignedUpload['upload']>, file: File, onProgress?: (fraction: number) => void): Promise<void> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const xhr = new XMLHttpRequest();
|
|
27
|
+
xhr.open(signed.method, signed.url);
|
|
28
|
+
for (const [name, value] of Object.entries(signed.headers)) xhr.setRequestHeader(name, value);
|
|
29
|
+
xhr.upload.onprogress = (e) => {
|
|
30
|
+
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
|
|
31
|
+
};
|
|
32
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`The store refused the file (${xhr.status})`)));
|
|
33
|
+
xhr.onerror = () => reject(new Error('The upload was interrupted — check the connection and try again'));
|
|
34
|
+
xhr.send(file);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ensureMediaUpload(di: DiContainer): void {
|
|
39
|
+
if (typeof window === 'undefined' || di.hasByToken(MEDIA_UPLOAD_TOKEN)) return;
|
|
40
|
+
di.registerValueOfToken(MEDIA_UPLOAD_TOKEN, {
|
|
41
|
+
async upload(file: File, onProgress?: (fraction: number) => void): Promise<string> {
|
|
42
|
+
const issue = mediaIssue(file.type, file.size);
|
|
43
|
+
if (issue) throw new Error(issue);
|
|
44
|
+
const res = await fetch(MEDIA_UPLOAD_PATH, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
|
|
48
|
+
});
|
|
49
|
+
const body = (await res.json().catch(() => null)) as ISignedUpload | null;
|
|
50
|
+
if (!res.ok || !body?.upload || !body.url) throw new Error(refusal(res.status, body));
|
|
51
|
+
await put(body.upload, file, onProgress);
|
|
52
|
+
return body.url;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A module's SIBLING by name: same namespace, same version — only the name swapped. How editor
|
|
2
|
+
// blocks address each other for the dock's open-by-reference: a module knows its own reference
|
|
3
|
+
// (useComponent), and its siblings shipped in the same build, so deriving is exact where a
|
|
4
|
+
// hard-coded reference would rot with every version bump.
|
|
5
|
+
import type { ITileReference } from '@mosetta/core';
|
|
6
|
+
|
|
7
|
+
export function siblingReference({ namespace, version }: ITileReference, name: string): string {
|
|
8
|
+
return `_ns_${namespace}_t_component_n_${name}_v_${version}`;
|
|
9
|
+
}
|