@jxsuite/studio 0.36.0 → 0.37.0
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/dist/iframe-entry.js +18 -9
- package/dist/iframe-entry.js.map +4 -4
- package/dist/studio.js +11525 -2877
- package/dist/studio.js.map +64 -17
- package/package.json +4 -2
- package/src/canvas/canvas-render.ts +85 -12
- package/src/canvas/iframe-host.ts +85 -0
- package/src/canvas/iframe-overlay.ts +30 -1
- package/src/collab/collab-session.ts +903 -0
- package/src/collab/collab-state.ts +71 -0
- package/src/collab/monaco-cursors.ts +69 -0
- package/src/collab/presence-chips.ts +58 -0
- package/src/files/file-ops.ts +6 -0
- package/src/files/files.ts +6 -0
- package/src/panels/git-panel.ts +5 -0
- package/src/panels/toolbar.ts +13 -3
- package/src/platforms/cloud.ts +31 -0
- package/src/platforms/devserver.ts +37 -0
- package/src/studio.ts +17 -0
- package/src/tabs/doc-op-apply.ts +5 -81
- package/src/tabs/patch-ops.ts +7 -23
- package/src/tabs/transact.ts +138 -12
- package/src/types.ts +8 -0
- package/src/workspace/workspace.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Jx Studio — visual builder for Jx documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
"@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
|
|
40
40
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
|
|
41
41
|
"@jxsuite/ai": "^0.33.0",
|
|
42
|
+
"@jxsuite/collab": "^0.2.0",
|
|
42
43
|
"@jxsuite/create": "^0.36.0",
|
|
43
44
|
"@jxsuite/parser": "^0.35.1",
|
|
44
|
-
"@jxsuite/protocol": "^0.
|
|
45
|
+
"@jxsuite/protocol": "^0.3.0",
|
|
45
46
|
"@jxsuite/runtime": "^0.34.2",
|
|
46
47
|
"@jxsuite/schema": "^0.35.0",
|
|
47
48
|
"@spectrum-web-components/accordion": "^1.12.1",
|
|
@@ -86,6 +87,7 @@
|
|
|
86
87
|
"lit": "^3.3.3",
|
|
87
88
|
"lit-html": "^3.3.3",
|
|
88
89
|
"monaco-editor": "^0.55.1",
|
|
90
|
+
"y-monaco": "^0.1.6",
|
|
89
91
|
"yaml": "^2.9.0"
|
|
90
92
|
},
|
|
91
93
|
"devDependencies": {
|
|
@@ -10,6 +10,9 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
|
|
|
10
10
|
|
|
11
11
|
import { canvasPanels, canvasWrap, updateCanvas } from "../store";
|
|
12
12
|
import { activeTab } from "../workspace/workspace";
|
|
13
|
+
import { collabSourceContext } from "../collab/collab-session";
|
|
14
|
+
import { attachCursorStyles } from "../collab/monaco-cursors";
|
|
15
|
+
import type { AwarenessLike } from "../collab/monaco-cursors";
|
|
13
16
|
import { view } from "../view";
|
|
14
17
|
import { parseSourceForPath, serializeDocument } from "../files/file-ops";
|
|
15
18
|
import { formatByName, formatForPath } from "../format/format-host";
|
|
@@ -130,11 +133,48 @@ function hardClearCanvasWrap() {
|
|
|
130
133
|
* the very next render is treated as a fresh mode transition and rebuilds the surface from
|
|
131
134
|
* scratch.
|
|
132
135
|
*/
|
|
136
|
+
/** Live source-mode collab binding teardown (unbind + release the canonical lock). */
|
|
137
|
+
let sourceCollabCleanup: (() => void) | null = null;
|
|
138
|
+
|
|
139
|
+
function disposeSourceCollab(): void {
|
|
140
|
+
sourceCollabCleanup?.();
|
|
141
|
+
sourceCollabCleanup = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bind the Monaco editor to the shared source text via y-monaco: two-way character-level sync plus
|
|
146
|
+
* remote in-buffer cursors/selections (decorated per-client; colors injected from the presence
|
|
147
|
+
* palette by {@link attachCursorStyles}). y-monaco/yjs evaluation defers behind the dynamic import
|
|
148
|
+
* until a code view actually binds. Returns the teardown.
|
|
149
|
+
*/
|
|
150
|
+
async function createSourceCollabBinding(
|
|
151
|
+
model: monaco.editor.ITextModel,
|
|
152
|
+
editor: monaco.editor.IStandaloneCodeEditor,
|
|
153
|
+
ctx: NonNullable<ReturnType<typeof collabSourceContext>>,
|
|
154
|
+
): Promise<() => void> {
|
|
155
|
+
const { MonacoBinding } = await import("y-monaco");
|
|
156
|
+
type YText = ConstructorParameters<typeof MonacoBinding>[0];
|
|
157
|
+
type BindingAwareness = ConstructorParameters<typeof MonacoBinding>[3];
|
|
158
|
+
const binding = new MonacoBinding(
|
|
159
|
+
ctx.text as YText,
|
|
160
|
+
model,
|
|
161
|
+
new Set([editor]),
|
|
162
|
+
ctx.awareness as BindingAwareness,
|
|
163
|
+
);
|
|
164
|
+
const detachStyles = attachCursorStyles(ctx.awareness as unknown as AwarenessLike, document);
|
|
165
|
+
return () => {
|
|
166
|
+
detachStyles();
|
|
167
|
+
binding.destroy();
|
|
168
|
+
ctx.leave();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
133
172
|
function resetCanvasView() {
|
|
134
173
|
if (view.functionEditor) {
|
|
135
174
|
view.functionEditor.dispose();
|
|
136
175
|
view.functionEditor = null;
|
|
137
176
|
}
|
|
177
|
+
disposeSourceCollab();
|
|
138
178
|
if (view.monacoEditor) {
|
|
139
179
|
view.monacoEditor.getModel()?.dispose();
|
|
140
180
|
view.monacoEditor.dispose();
|
|
@@ -305,6 +345,7 @@ export function renderCanvas() {
|
|
|
305
345
|
}
|
|
306
346
|
|
|
307
347
|
// Dispose Monaco editor if switching away from source mode
|
|
348
|
+
disposeSourceCollab();
|
|
308
349
|
if (view.monacoEditor) {
|
|
309
350
|
view.monacoEditor.getModel()?.dispose();
|
|
310
351
|
view.monacoEditor.dispose();
|
|
@@ -368,17 +409,45 @@ export function renderCanvas() {
|
|
|
368
409
|
const lang = sourceLang(tab);
|
|
369
410
|
const modelUri = monaco.Uri.parse(`file:///${filePath}`);
|
|
370
411
|
const model = monaco.editor.createModel("", lang, modelUri);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
412
|
+
// Co-edited tabs bind the buffer to the shared Y.Text instead of a local serialization: the
|
|
413
|
+
// Canonical lock flips to "source", peers co-type character-level, and the source reconciler
|
|
414
|
+
// Parses back into the structure tree for everyone's canvas.
|
|
415
|
+
const collabCtx = collabSourceContext(tab);
|
|
416
|
+
if (collabCtx) {
|
|
417
|
+
void collabCtx
|
|
418
|
+
.enter()
|
|
419
|
+
.then(async () => {
|
|
420
|
+
const editor = view.monacoEditor;
|
|
421
|
+
if (!editor || editor.getModel() !== model) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const cleanup = await createSourceCollabBinding(model, editor, collabCtx);
|
|
425
|
+
// The editor may have been torn down while y-monaco loaded — unbind immediately.
|
|
426
|
+
if (view.monacoEditor !== editor || editor.getModel() !== model) {
|
|
427
|
+
cleanup();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
sourceCollabCleanup = cleanup;
|
|
431
|
+
if (collabCtx.readOnly) {
|
|
432
|
+
editor.updateOptions({ readOnly: true });
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
.catch(() => {
|
|
436
|
+
// Binding failures degrade to a read-only-ish local buffer; the session stays live.
|
|
437
|
+
});
|
|
438
|
+
} else {
|
|
439
|
+
sourceContent(tab, lang)
|
|
440
|
+
.then((content) => {
|
|
441
|
+
const editor = view.monacoEditor;
|
|
442
|
+
if (editor && editor.getModel() === model) {
|
|
443
|
+
editor._ignoreNextChange = true;
|
|
444
|
+
model.setValue(content);
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
.catch(() => {
|
|
448
|
+
// Serialization unavailable — leave the buffer empty rather than crash the render
|
|
449
|
+
});
|
|
450
|
+
}
|
|
382
451
|
view.monacoEditor = monaco.editor.create(editorContainer as unknown as HTMLElement, {
|
|
383
452
|
automaticLayout: true,
|
|
384
453
|
fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace",
|
|
@@ -392,7 +461,11 @@ export function renderCanvas() {
|
|
|
392
461
|
wordWrap: "on",
|
|
393
462
|
});
|
|
394
463
|
|
|
395
|
-
// Debounced sync back to state
|
|
464
|
+
// Debounced sync back to state (solo tabs only: co-edited buffers flow through the shared
|
|
465
|
+
// Y.Text and the source reconciler's parse mirror instead of a whole-doc replace).
|
|
466
|
+
if (collabCtx) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
396
469
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
397
470
|
view.monacoEditor.onDidChangeModelContent(() => {
|
|
398
471
|
const editor = view.monacoEditor;
|
|
@@ -25,6 +25,7 @@ import { rectOf } from "../utils/geometry";
|
|
|
25
25
|
import { effect, effectScope } from "../reactivity";
|
|
26
26
|
import { canvasPanels, canvasWrap, pathsEqual, renderOnly, updateCanvas, updateUi } from "../store";
|
|
27
27
|
import { activeTab, workspace } from "../workspace/workspace";
|
|
28
|
+
import { collabState } from "../collab/collab-state";
|
|
28
29
|
import { getPlatform, hasPlatform } from "../platform";
|
|
29
30
|
import type {
|
|
30
31
|
ApplyFormatIntent,
|
|
@@ -63,6 +64,10 @@ interface HostState {
|
|
|
63
64
|
selectionPath: (string | number)[] | null;
|
|
64
65
|
/** Id of the most recent selection `measure` request, so stale `geometry` replies are dropped. */
|
|
65
66
|
selReqId: number;
|
|
67
|
+
/** Id of the most recent presence `measure` request (allocated from the selReqId counter). */
|
|
68
|
+
presenceReqId: number;
|
|
69
|
+
/** Serialized peer path → presence box meta for the in-flight presence measure. */
|
|
70
|
+
presenceMeta: Map<string, { color: string; label: string }>;
|
|
66
71
|
/** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
|
|
67
72
|
editing: boolean;
|
|
68
73
|
/** The latest selection snapshot from this host's iframe (active tags + caret rect + link). */
|
|
@@ -478,6 +483,69 @@ function ensureSelectionWatch(): void {
|
|
|
478
483
|
selectionWatch = { stop: () => scope.stop() };
|
|
479
484
|
}
|
|
480
485
|
|
|
486
|
+
/** Lazily start one reactive watcher that re-measures remote peers' selections in every host. */
|
|
487
|
+
let presenceWatchStarted = false;
|
|
488
|
+
let presenceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
489
|
+
|
|
490
|
+
function ensurePresenceWatch(): void {
|
|
491
|
+
if (presenceWatchStarted) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
presenceWatchStarted = true;
|
|
495
|
+
const scope = effectScope(true);
|
|
496
|
+
scope.run(() => {
|
|
497
|
+
effect(() => {
|
|
498
|
+
const tab = activeTab.value;
|
|
499
|
+
if (tab) {
|
|
500
|
+
// Track the roster deeply enough that selection moves re-trigger.
|
|
501
|
+
const { peers } = collabState(tab);
|
|
502
|
+
void peers.map((peer) => JSON.stringify(peer.state.structuralSelection ?? null)).join("|");
|
|
503
|
+
}
|
|
504
|
+
if (presenceTimer) {
|
|
505
|
+
clearTimeout(presenceTimer);
|
|
506
|
+
}
|
|
507
|
+
// Debounced: awareness updates arrive per cursor move.
|
|
508
|
+
presenceTimer = setTimeout(() => {
|
|
509
|
+
presenceTimer = null;
|
|
510
|
+
for (const host of liveHosts) {
|
|
511
|
+
requestPresence(host);
|
|
512
|
+
}
|
|
513
|
+
}, 100);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Measure remote peers' selections in this host's iframe and draw colored boxes from the reply. */
|
|
519
|
+
function requestPresence(host: HostState): void {
|
|
520
|
+
if (host.stylebook || !host.ready || !host.iframe.isConnected) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const tab = activeTab.value;
|
|
524
|
+
const peers = tab ? collabState(tab).peers : [];
|
|
525
|
+
host.presenceMeta.clear();
|
|
526
|
+
const paths: (string | number)[][] = [];
|
|
527
|
+
for (const peer of peers) {
|
|
528
|
+
const { structuralSelection } = peer.state;
|
|
529
|
+
if (!structuralSelection || peer.state.focusedPath !== tab?.documentPath) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const path = [...structuralSelection];
|
|
533
|
+
paths.push(path);
|
|
534
|
+
host.presenceMeta.set(JSON.stringify(path), {
|
|
535
|
+
color: peer.state.user.color,
|
|
536
|
+
label: peer.state.user.name ?? peer.state.user.login,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (paths.length === 0) {
|
|
540
|
+
host.presenceReqId = -1;
|
|
541
|
+
host.overlay.setPresence([]);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
host.selReqId += 1;
|
|
545
|
+
host.presenceReqId = host.selReqId;
|
|
546
|
+
host.channel.post({ kind: "measure", paths, reqId: host.presenceReqId });
|
|
547
|
+
}
|
|
548
|
+
|
|
481
549
|
/** Track the selection on a host and ask its iframe to measure it (or clear the box when null). */
|
|
482
550
|
function requestSelection(host: HostState, sel: (string | number)[] | null): void {
|
|
483
551
|
if (host.stylebook) {
|
|
@@ -608,6 +676,8 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
608
676
|
pending: null,
|
|
609
677
|
pendingEnterEdit: null,
|
|
610
678
|
pendingTabIds: new Map(),
|
|
679
|
+
presenceMeta: new Map(),
|
|
680
|
+
presenceReqId: -1,
|
|
611
681
|
ready: false,
|
|
612
682
|
selectionPath: null,
|
|
613
683
|
selReqId: 0,
|
|
@@ -625,6 +695,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
625
695
|
hosts.set(canvasEl, state);
|
|
626
696
|
liveHosts.add(state);
|
|
627
697
|
ensureSelectionWatch();
|
|
698
|
+
ensurePresenceWatch();
|
|
628
699
|
return state;
|
|
629
700
|
}
|
|
630
701
|
|
|
@@ -767,6 +838,19 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
767
838
|
return;
|
|
768
839
|
}
|
|
769
840
|
case "geometry": {
|
|
841
|
+
// Remote-presence reply: draw one colored box per measured peer selection.
|
|
842
|
+
if (msg.reqId === state.presenceReqId) {
|
|
843
|
+
state.presenceReqId = -1;
|
|
844
|
+
const items: { placement: OverlayPlacement; color: string; label: string }[] = [];
|
|
845
|
+
for (const hit of msg.hits) {
|
|
846
|
+
const meta = state.presenceMeta.get(JSON.stringify(hit.path));
|
|
847
|
+
if (meta) {
|
|
848
|
+
items.push({ ...meta, placement: canvasRectToParent(hit.rect) });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
state.overlay.setPresence(items);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
770
854
|
// Pan-to-card reply (stylebook): convert the card's iframe rect to parent-viewport space by
|
|
771
855
|
// The empirical zoom + iframe offset and center it.
|
|
772
856
|
if (msg.reqId === state.panReqId) {
|
|
@@ -1047,6 +1131,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1047
1131
|
function onDomUpdated(state: HostState, gen: number): void {
|
|
1048
1132
|
state.lastRenderedGen = gen;
|
|
1049
1133
|
requestSelection(state, state.selectionPath);
|
|
1134
|
+
requestPresence(state);
|
|
1050
1135
|
hideInsertZoneNow(state);
|
|
1051
1136
|
if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
|
|
1052
1137
|
const { path } = state.pendingEnterEdit;
|
|
@@ -65,6 +65,11 @@ export interface OverlayLayer {
|
|
|
65
65
|
* omitted/null hides it.
|
|
66
66
|
*/
|
|
67
67
|
setSelection: (placement: OverlayPlacement | null, label?: string | null) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Draw remote collaborators' selection boxes (colored outline + name tag). Replaces the whole set
|
|
70
|
+
* each call; pass [] to clear. Placements are overlay-local coords like setSelection's.
|
|
71
|
+
*/
|
|
72
|
+
setPresence: (items: { placement: OverlayPlacement; color: string; label: string }[]) => void;
|
|
68
73
|
/** Position the hover box (or hide it when `placement` is null). */
|
|
69
74
|
setHover: (placement: OverlayPlacement | null) => void;
|
|
70
75
|
/**
|
|
@@ -143,7 +148,11 @@ export function createOverlayLayer(doc: Document = document): OverlayLayer {
|
|
|
143
148
|
insertButton.style.pointerEvents = "auto";
|
|
144
149
|
insertButton.style.display = "none";
|
|
145
150
|
|
|
146
|
-
|
|
151
|
+
// Remote collaborators' selection boxes live in their own container, replaced wholesale.
|
|
152
|
+
const presenceGroup = doc.createElement("div");
|
|
153
|
+
presenceGroup.className = "overlay-presence-group";
|
|
154
|
+
|
|
155
|
+
root.append(hoverBox, selectionBox, dropBox, insertButton, presenceGroup);
|
|
147
156
|
|
|
148
157
|
return {
|
|
149
158
|
dispose: () => root.remove(),
|
|
@@ -152,6 +161,26 @@ export function createOverlayLayer(doc: Document = document): OverlayLayer {
|
|
|
152
161
|
setDropIndicator: (placement, edge = "inside") => placeDropIndicator(dropBox, placement, edge),
|
|
153
162
|
setHover: (placement) => place(hoverBox, placement),
|
|
154
163
|
setInsertZone: (placement, edge = "center") => placeInsertButton(insertButton, placement, edge),
|
|
164
|
+
setPresence: (items) => {
|
|
165
|
+
presenceGroup.replaceChildren();
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const box = doc.createElement("div");
|
|
168
|
+
box.className = "overlay-box overlay-presence";
|
|
169
|
+
box.style.cssText =
|
|
170
|
+
`position:absolute;display:block;pointer-events:none;` +
|
|
171
|
+
`outline:1.5px solid ${item.color};outline-offset:1px;` +
|
|
172
|
+
`left:${item.placement.left}px;top:${item.placement.top}px;` +
|
|
173
|
+
`width:${item.placement.width}px;height:${item.placement.height}px`;
|
|
174
|
+
const tag = doc.createElement("div");
|
|
175
|
+
tag.className = "overlay-presence-tag";
|
|
176
|
+
tag.textContent = item.label;
|
|
177
|
+
tag.style.cssText =
|
|
178
|
+
`position:absolute;top:-18px;left:-2px;padding:1px 5px;border-radius:3px;` +
|
|
179
|
+
`font:10px/1.4 sans-serif;color:#fff;white-space:nowrap;background:${item.color}`;
|
|
180
|
+
box.append(tag);
|
|
181
|
+
presenceGroup.append(box);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
155
184
|
setSelection: (placement, label = null) => {
|
|
156
185
|
place(selectionBox, placement);
|
|
157
186
|
if (placement && label) {
|