@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.36.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.2.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
- sourceContent(tab, lang)
372
- .then((content) => {
373
- const editor = view.monacoEditor;
374
- if (editor && editor.getModel() === model) {
375
- editor._ignoreNextChange = true;
376
- model.setValue(content);
377
- }
378
- })
379
- .catch(() => {
380
- // Serialization unavailable leave the buffer empty rather than crash the render
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
- root.append(hoverBox, selectionBox, dropBox, insertButton);
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) {