@formicoidea/labre-core 0.38.1 → 0.39.1

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.
@@ -167,14 +167,17 @@ export class RichTextCell extends BaseCellRenderer {
167
167
  e.stopPropagation();
168
168
  };
169
169
  this._onPaste = (e) => {
170
- e.preventDefault();
171
- e.stopPropagation();
172
170
  const inlineEditor = this.inlineEditor$.value;
173
- if (!inlineEditor)
174
- return;
175
- const inlineRange = inlineEditor.getInlineRange();
176
- if (!inlineRange)
171
+ const inlineRange = inlineEditor?.getInlineRange();
172
+ // Bail out before cancelling the event: with no resolvable inline range
173
+ // there is nothing to insert into, and swallowing the paste here would
174
+ // drop the clipboard silently instead of letting the default path act.
175
+ if (!inlineEditor || !inlineRange)
177
176
  return;
177
+ // The cell owns the paste: stop it before the document clipboard sees it,
178
+ // whichever branch below ends up handling the payload.
179
+ e.preventDefault();
180
+ e.stopPropagation();
178
181
  if (e.clipboardData) {
179
182
  try {
180
183
  const getDeltas = (snapshot) => {
@@ -290,9 +293,11 @@ export class RichTextCell extends BaseCellRenderer {
290
293
  richText.addEventListener('cut', this._onCut, true);
291
294
  richText.addEventListener('paste', this._onPaste, true);
292
295
  return () => {
293
- richText.removeEventListener('copy', this._onCopy);
294
- richText.removeEventListener('cut', this._onCut);
295
- richText.removeEventListener('paste', this._onPaste);
296
+ // the capture flag is part of the listener identity: without it
297
+ // these three would never be removed.
298
+ richText.removeEventListener('copy', this._onCopy, true);
299
+ richText.removeEventListener('cut', this._onCut, true);
300
+ richText.removeEventListener('paste', this._onPaste, true);
296
301
  };
297
302
  }
298
303
  return;
@@ -259,6 +259,15 @@ export class EdgelessFrameManager extends GfxExtension {
259
259
  }
260
260
  }));
261
261
  this._disposable.add(doc.slots.blockUpdated.subscribe(payload => {
262
+ // Local adds only, exactly like the canvas-element half above. A block
263
+ // added by a remote peer was already adopted THERE: the frame's
264
+ // `childElementIds` arrives with the same sync. Adopting it again here
265
+ // is a duplicate write on every receiving peer — and a readonly viewer
266
+ // cannot write at all, which is how this surfaced (#251). See #242 for
267
+ // the rule: a cascade reacts to local transactions, the receiving peer
268
+ // trusts the sync.
269
+ if (!payload.isLocal)
270
+ return;
262
271
  if (payload.type === 'add' &&
263
272
  payload.model instanceof GfxBlockElementModel &&
264
273
  renderableInEdgeless(doc, surfaceModel, payload.model)) {
@@ -275,15 +284,7 @@ export class EdgelessFrameManager extends GfxExtension {
275
284
  if (this._enclosesFrame(payload.model, frame)) {
276
285
  return;
277
286
  }
278
- if (payload.isLocal) {
279
- this._adoptNewlyCreatedElement(frame, payload.model);
280
- }
281
- else {
282
- // Remote adds: adopt only. The originating client reassigns the
283
- // index and syncs it — reindexing here too would produce
284
- // competing concurrent writes for the same element.
285
- this.addElementsToFrame(frame, [payload.model]);
286
- }
287
+ this._adoptNewlyCreatedElement(frame, payload.model);
287
288
  }
288
289
  }));
289
290
  }
@@ -84,7 +84,19 @@ export declare class ConnectorPathGenerator extends PathGenerator {
84
84
  constructor(options: {
85
85
  getElementById: (id: string) => GfxModel | null;
86
86
  });
87
- static updatePath(connector: ConnectorElementModel | LocalConnectorElementModel, path: PointLocation[] | null, elementGetter?: (id: string) => GfxModel | null): void;
87
+ static updatePath(connector: ConnectorElementModel | LocalConnectorElementModel, path: PointLocation[] | null, elementGetter?: (id: string) => GfxModel | null, options?: {
88
+ /**
89
+ * Whether this peer may persist the derived label box. `labelXYWH` is a
90
+ * `@field()`: unlike `path` / `xywh`, which are `@local()` and must be
91
+ * recomputed everywhere to repaint, writing it makes the CRDT grow on
92
+ * every peer that merely WATCHES a connector move — and throws on a
93
+ * readonly viewer. The authoring peer's value arrives with the sync, so
94
+ * a peer reacting to a remote change passes `false`. Defaults to `true`
95
+ * for the direct callers (paste, mindmap layout, shape resize), which
96
+ * are local gestures by construction.
97
+ */
98
+ persistLabelXYWH?: boolean;
99
+ }): void;
88
100
  private _computeStartEndPoint;
89
101
  private _generateConnectorPath;
90
102
  private _generateCurveConnectorPath;
@@ -978,7 +978,7 @@ export class ConnectorPathGenerator extends PathGenerator {
978
978
  super();
979
979
  this.options = options;
980
980
  }
981
- static updatePath(connector, path, elementGetter) {
981
+ static updatePath(connector, path, elementGetter, options) {
982
982
  const instance = new ConnectorPathGenerator({
983
983
  getElementById: elementGetter ?? (() => null),
984
984
  });
@@ -1004,12 +1004,16 @@ export class ConnectorPathGenerator extends PathGenerator {
1004
1004
  // the property assignment order matters
1005
1005
  connector.xywh = bound.serialize();
1006
1006
  connector.path = relativePoints;
1007
- // Updates Connector's Label position.
1007
+ // Updates Connector's Label position — a PERSISTED write, unlike
1008
+ // everything above it.
1008
1009
  if (isConnectorWithLabel(connector)) {
1009
1010
  const model = connector;
1010
- const [cx, cy] = model.getPointByOffsetDistance(model.labelOffset.distance);
1011
- const [, , w, h] = model.labelXYWH;
1012
- model.labelXYWH = [cx - w / 2, cy - h / 2, w, h];
1011
+ const mayPersist = (options?.persistLabelXYWH ?? true) && !model.surface?.store?.readonly;
1012
+ if (mayPersist) {
1013
+ const [cx, cy] = model.getPointByOffsetDistance(model.labelOffset.distance);
1014
+ const [, , w, h] = model.labelXYWH;
1015
+ model.labelXYWH = [cx - w / 2, cy - h / 2, w, h];
1016
+ }
1013
1017
  }
1014
1018
  connector.updatingPath = false;
1015
1019
  }
@@ -62,49 +62,74 @@ function reanchorConnectorsForPolygon(surface, polygonId, elementGetter) {
62
62
  export const connectorWatcher = (surface) => {
63
63
  const hasElementById = (id) => surface.hasElementById(id) || surface.store.hasBlock(id);
64
64
  const elementGetter = (id) => surface.getElementById(id) ?? surface.store.getModelById(id);
65
- const updateConnectorPath = (connector) => {
65
+ const updateConnectorPath = (connector, local) => {
66
66
  if (((connector.source?.id && hasElementById(connector.source.id)) ||
67
67
  (!connector.source?.id && connector.source?.position)) &&
68
68
  ((connector.target?.id && hasElementById(connector.target.id)) ||
69
69
  (!connector.target?.id && connector.target?.position))) {
70
- ConnectorPathGenerator.updatePath(connector, null, elementGetter);
70
+ // The path itself is `@local()` — every peer recomputes it to repaint.
71
+ // The label box is not: only the peer that authored the change persists
72
+ // it, the others read the value that arrives with the sync.
73
+ ConnectorPathGenerator.updatePath(connector, null, elementGetter, {
74
+ persistLabelXYWH: local,
75
+ });
71
76
  }
72
77
  };
73
- const pendingList = new Set();
78
+ /**
79
+ * Connectors whose path must be recomputed in the next microtask, mapped to
80
+ * whether ANY of the changes that queued them was local — a single local
81
+ * gesture is enough to let the peer persist the derived label box.
82
+ */
83
+ const pendingList = new Map();
74
84
  let pendingFlag = false;
75
- const addToUpdateList = (connector) => {
76
- pendingList.add(connector);
85
+ const addToUpdateList = (connector, local) => {
86
+ pendingList.set(connector, (pendingList.get(connector) ?? false) || local);
77
87
  if (!pendingFlag) {
78
88
  pendingFlag = true;
79
89
  queueMicrotask(() => {
80
- pendingList.forEach(updateConnectorPath);
90
+ pendingList.forEach((isLocal, connector) => updateConnectorPath(connector, isLocal));
81
91
  pendingList.clear();
82
92
  pendingFlag = false;
83
93
  });
84
94
  }
85
95
  };
86
96
  const disposables = [
87
- surface.elementAdded.subscribe(({ id }) => {
97
+ surface.elementAdded.subscribe(({ id, local }) => {
88
98
  const element = elementGetter(id);
89
99
  if (!element)
90
100
  return;
91
101
  if ('type' in element && element.type === 'connector') {
92
- addToUpdateList(element);
102
+ addToUpdateList(element, local);
93
103
  }
94
104
  else {
95
- surface.getConnectors(id).forEach(addToUpdateList);
105
+ surface
106
+ .getConnectors(id)
107
+ .forEach(connector => addToUpdateList(connector, local));
96
108
  }
97
109
  }),
98
- surface.elementUpdated.subscribe(({ id, props }) => {
110
+ surface.elementUpdated.subscribe(({ id, props, local }) => {
99
111
  const element = elementGetter(id);
100
- if (props['vertices']) {
112
+ // The element may already be gone by the time the update is delivered
113
+ // (a remote peer that moved and then deleted it, an undo…).
114
+ if (!element)
115
+ return;
116
+ if (local && props['vertices']) {
101
117
  // When polygon vertices change, re-anchor connected connectors to the
102
118
  // nearest valid boundary point BEFORE scheduling the path update, so
103
119
  // the path generator uses the corrected anchor positions.
120
+ //
121
+ // LOCAL ONLY: re-anchoring writes `source`/`target`, both persisted
122
+ // `@field()`s. The peer that edited the vertices re-anchors and syncs
123
+ // the result, so doing it again on a receiving peer is a duplicate
124
+ // write — and an illegal one on a readonly viewer (see #242).
104
125
  reanchorConnectorsForPolygon(surface, id, elementGetter);
105
126
  }
106
127
  if (props['xywh'] || props['rotate'] || props['vertices']) {
107
- surface.getConnectors(id).forEach(addToUpdateList);
128
+ // Not gated on `local`: `path`, `absolutePath` and the connector's own
129
+ // `xywh` are `@local()`, so every peer must recompute them to repaint.
130
+ surface
131
+ .getConnectors(id)
132
+ .forEach(connector => addToUpdateList(connector, local));
108
133
  }
109
134
  if ('type' in element &&
110
135
  element.type === 'connector' &&
@@ -113,25 +138,31 @@ export const connectorWatcher = (surface) => {
113
138
  props['source'] ||
114
139
  props['curveControlPoint'] !== undefined)) {
115
140
  const connector = element;
116
- // Clear custom handle data when connector mode changes
117
- if (props['mode'] !== undefined) {
141
+ // Clear custom handle data when connector mode changes. `local` only:
142
+ // `curveControlPoint` is persisted, and the peer that changed the mode
143
+ // cleared it in the same gesture.
144
+ if (local && props['mode'] !== undefined) {
118
145
  if (connector.curveControlPoint !== null) {
119
146
  connector.curveControlPoint = null;
120
147
  }
121
148
  }
122
- addToUpdateList(connector);
149
+ addToUpdateList(connector, local);
123
150
  }
124
151
  }),
125
152
  surface.store.slots.blockUpdated.subscribe(payload => {
126
153
  if (payload.type === 'add' ||
127
154
  (payload.type === 'update' && payload.props.key === 'xywh')) {
128
- surface.getConnectors(payload.id).forEach(addToUpdateList);
155
+ surface
156
+ .getConnectors(payload.id)
157
+ .forEach(connector => addToUpdateList(connector, payload.isLocal));
129
158
  }
130
159
  }),
131
160
  ];
161
+ // Mount-time recompute of the `@local()` geometry. Not a local gesture:
162
+ // merely OPENING a document must not rewrite every connector's label box.
132
163
  surface
133
164
  .getElementsByType('connector')
134
- .forEach(connector => updateConnectorPath(connector));
165
+ .forEach(connector => updateConnectorPath(connector, false));
135
166
  return () => {
136
167
  disposables.forEach(d => d.unsubscribe());
137
168
  };
@@ -44,7 +44,11 @@ export class ConnectorElementView extends GfxElementModelView {
44
44
  };
45
45
  };
46
46
  const watchEvent = (labelModel) => {
47
+ // `ViewManager.get` returns null for an element that has no view yet —
48
+ // the local label element is registered a beat before its view exists.
47
49
  const view = this.gfx.view.get(labelModel);
50
+ if (!view)
51
+ return;
48
52
  const connectorModel = this.model;
49
53
  let labelBound = null;
50
54
  let startPoint = {
@@ -173,12 +173,18 @@ export class MindMapView extends GfxElementModelView {
173
173
  button: false,
174
174
  node: false,
175
175
  };
176
+ this._hoveredState = this._hoveredState.set(node.id, hoveredState);
177
+ // `ViewManager.get` returns null for an element whose view is not
178
+ // mounted yet — the button model exists a beat before its view does.
179
+ // The button is created and tracked either way; only the hover wiring
180
+ // is skipped.
176
181
  const buttonView = this.gfx.view.get(id);
182
+ if (!buttonView)
183
+ return collapseButton;
177
184
  const isOnElementBound = (evt) => {
178
185
  const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
179
186
  return buttonView.model.includesPoint(x, y, { useElementBound: true }, this.gfx.std.host);
180
187
  };
181
- this._hoveredState = this._hoveredState.set(node.id, hoveredState);
182
188
  buttonView.on('pointerenter', () => {
183
189
  hoveredState.button = true;
184
190
  this._updateButtonVisibility(node.id);
@@ -216,6 +222,8 @@ export class MindMapView extends GfxElementModelView {
216
222
  }
217
223
  });
218
224
  const nodeView = this.gfx.view.get(node.id);
225
+ if (!nodeView)
226
+ return collapseButton;
219
227
  nodeView.on('pointerenter', () => {
220
228
  hoveredState.node = true;
221
229
  this._updateButtonVisibility(node.id);
@@ -1,5 +1,5 @@
1
1
  import { ViewExtensionProvider, } from '../affine-ext-loader/index.js';
2
- import { extendTemplateCategory } from '../affine-gfx-template/index.js';
2
+ import { TemplateCategoryExtension } from '../affine-gfx-template/index.js';
3
3
  import { effects } from './effects.js';
4
4
  import { MindmapElementRendererExtension } from './element-renderer.js';
5
5
  import { MindMapIndicatorOverlay } from './indicator-overlay/index.js';
@@ -22,7 +22,6 @@ export class MindmapRenderViewExtension extends ViewExtensionProvider {
22
22
  effect() {
23
23
  super.effect();
24
24
  effects();
25
- extendTemplateCategory(mindmapTemplateCategory);
26
25
  }
27
26
  setup(context) {
28
27
  super.setup(context);
@@ -33,6 +32,12 @@ export class MindmapRenderViewExtension extends ViewExtensionProvider {
33
32
  context.register(MindMapDragExtension);
34
33
  context.register(MindMapIndicatorOverlay);
35
34
  context.register(MindMapInteraction);
35
+ if (this.isEdgeless(context.scope)) {
36
+ // The Mind Map Templates-panel category stays on the ALWAYS-ON half, on
37
+ // purpose: it must survive the `mindmap` flag, which only hides the
38
+ // senior button ({@link MindmapToolViewExtension}).
39
+ context.register(TemplateCategoryExtension(mindmapTemplateCategory));
40
+ }
36
41
  }
37
42
  }
38
43
  /** The dedicated "Mind Map" senior button — flag-gated (`mindmap`). */
@@ -1,9 +1,34 @@
1
- import type { TemplateCategory } from './toolbar/template-type.js';
1
+ import type { BlockStdScope } from '../std/index.js';
2
+ import type { ExtensionType } from '../store/index.js';
3
+ import type { TemplateCategory, TemplateManager } from './toolbar/template-type.js';
2
4
  /**
3
- * Contribute a template category to the edgeless template panel. Idempotent per
4
- * category name (a framework's ViewExtension `effect()` may run more than once).
5
- * Lets each framework package own its template definitions while the panel
6
- * aggregates every contributed category. The future favorites feature reads the
7
- * same registry to decide which framework toolbars to show.
5
+ * A framework registers its Templates-panel categories here; the panel reads
6
+ * them back from the std of the edgeless it opens on. One identifier per
7
+ * category NAME, so two extensions claiming the same category collide at
8
+ * setup (`DuplicateServiceDefinitionError`, caught by `di-mount.unit.spec.ts`)
9
+ * rather than listing it twice.
8
10
  */
9
- export declare function extendTemplateCategory(category: TemplateCategory): void;
11
+ export declare const TemplateCategoryIdentifier: import("../global/di/types.js").ServiceIdentifier<TemplateCategory> & (<U extends TemplateCategory = TemplateCategory>(variant: import("../global/di/types.js").ServiceVariant) => import("../global/di/types.js").ServiceIdentifier<U>);
12
+ /**
13
+ * Register a framework's template categories. Call it from `setup()` of the
14
+ * FLAG-GATED view extension, beside the senior tool — a category is creation
15
+ * tooling, so a disabled framework must contribute none (`docs/adr/0009`):
16
+ *
17
+ * ```ts
18
+ * context.register(TemplateCategoryExtension(wardleyTemplateCategory));
19
+ * ```
20
+ *
21
+ * Replaces the module-level registry of 0.38.0–0.38.2 (`extendTemplateCategory`)
22
+ * that appended from `effect()` and never removed: a category outlived the
23
+ * editor whose flags had admitted it, so a framework switched off in the host
24
+ * kept its category until a full reload (#244). The DI container is per editor;
25
+ * nothing has to be un-registered.
26
+ */
27
+ export declare function TemplateCategoryExtension(...categories: TemplateCategory[]): ExtensionType;
28
+ /**
29
+ * The catalogue ONE editor sees: the built-in categories (`Other`, plus
30
+ * whatever a host appended through `EdgelessTemplatePanel.templates.extend`)
31
+ * followed by the categories the view extensions mounted on this `std`
32
+ * registered — i.e. exactly the frameworks whose tooling is on.
33
+ */
34
+ export declare function templateManagerFor(std: BlockStdScope): TemplateManager;
@@ -1,32 +1,76 @@
1
- import { EdgelessTemplatePanel } from './toolbar/template-panel.js';
1
+ import { createIdentifier } from '../global/di/index.js';
2
+ import { builtInTemplates } from './toolbar/builtin-templates.js';
3
+ /**
4
+ * A framework registers its Templates-panel categories here; the panel reads
5
+ * them back from the std of the edgeless it opens on. One identifier per
6
+ * category NAME, so two extensions claiming the same category collide at
7
+ * setup (`DuplicateServiceDefinitionError`, caught by `di-mount.unit.spec.ts`)
8
+ * rather than listing it twice.
9
+ */
10
+ export const TemplateCategoryIdentifier = createIdentifier('TemplateCategory');
11
+ /**
12
+ * Register a framework's template categories. Call it from `setup()` of the
13
+ * FLAG-GATED view extension, beside the senior tool — a category is creation
14
+ * tooling, so a disabled framework must contribute none (`docs/adr/0009`):
15
+ *
16
+ * ```ts
17
+ * context.register(TemplateCategoryExtension(wardleyTemplateCategory));
18
+ * ```
19
+ *
20
+ * Replaces the module-level registry of 0.38.0–0.38.2 (`extendTemplateCategory`)
21
+ * that appended from `effect()` and never removed: a category outlived the
22
+ * editor whose flags had admitted it, so a framework switched off in the host
23
+ * kept its category until a full reload (#244). The DI container is per editor;
24
+ * nothing has to be un-registered.
25
+ */
26
+ export function TemplateCategoryExtension(...categories) {
27
+ return {
28
+ setup: di => {
29
+ for (const category of categories) {
30
+ di.addImpl(TemplateCategoryIdentifier(category.name), () => category);
31
+ }
32
+ },
33
+ };
34
+ }
2
35
  function loadCategory(category) {
3
36
  return Array.isArray(category.templates)
4
37
  ? Promise.resolve(category.templates)
5
38
  : category.templates();
6
39
  }
7
- /** Wrap a single {@link TemplateCategory} as a {@link TemplateManager}. */
8
- function categoryManager(category) {
40
+ /**
41
+ * The catalogue ONE editor sees: the built-in categories (`Other`, plus
42
+ * whatever a host appended through `EdgelessTemplatePanel.templates.extend`)
43
+ * followed by the categories the view extensions mounted on this `std`
44
+ * registered — i.e. exactly the frameworks whose tooling is on.
45
+ */
46
+ export function templateManagerFor(std) {
47
+ const contributed = [
48
+ ...std.provider.getAll(TemplateCategoryIdentifier).values(),
49
+ ];
50
+ const byName = (name) => contributed.find(c => c.name === name);
9
51
  return {
10
- categories: () => [category.name],
11
- list: async (cate) => cate === category.name ? await loadCategory(category) : [],
12
- search: async (keyword) => {
13
- const all = await loadCategory(category);
52
+ categories: async () => [
53
+ ...new Set([
54
+ ...(await builtInTemplates.categories()),
55
+ ...contributed.map(c => c.name),
56
+ ]),
57
+ ],
58
+ // Both halves, so a host's `extend(...)` can still append to a framework's
59
+ // own category (the global half answers `[]` for a name it does not know).
60
+ list: async (name) => {
61
+ const category = byName(name);
62
+ return [
63
+ ...(category ? await loadCategory(category) : []),
64
+ ...(await builtInTemplates.list(name)),
65
+ ];
66
+ },
67
+ search: async (keyword, name) => {
14
68
  const k = keyword.trim().toLocaleLowerCase();
15
- return all.filter(t => t.name?.toLocaleLowerCase().includes(k));
69
+ const pool = name ? [byName(name)].filter(c => !!c) : contributed;
70
+ const own = (await Promise.all(pool.map(loadCategory)))
71
+ .flat()
72
+ .filter(t => t.name?.toLocaleLowerCase().includes(k));
73
+ return [...(await builtInTemplates.search(keyword, name)), ...own];
16
74
  },
17
75
  };
18
76
  }
19
- const registered = new Set();
20
- /**
21
- * Contribute a template category to the edgeless template panel. Idempotent per
22
- * category name (a framework's ViewExtension `effect()` may run more than once).
23
- * Lets each framework package own its template definitions while the panel
24
- * aggregates every contributed category. The future favorites feature reads the
25
- * same registry to decide which framework toolbars to show.
26
- */
27
- export function extendTemplateCategory(category) {
28
- if (registered.has(category.name))
29
- return;
30
- registered.add(category.name);
31
- EdgelessTemplatePanel.templates.extend(categoryManager(category));
32
- }
@@ -5,12 +5,19 @@ import type { Template } from './template-type.js';
5
5
  declare const EdgelessTemplatePanel_base: typeof LitElement & import("../../global/utils/types.js").Constructor<import("../../global/lit/with-disposable.js").DisposableClass>;
6
6
  export declare class EdgelessTemplatePanel extends EdgelessTemplatePanel_base {
7
7
  static styles: import("lit").CSSResult;
8
+ /**
9
+ * The GLOBAL half of the catalogue — `Other` and a host's `extend(...)`.
10
+ * Framework categories are not here: they live in the editor's DI container
11
+ * (`TemplateCategoryExtension`) and are read through {@link catalogue}.
12
+ */
8
13
  static templates: {
9
14
  list: (category: string) => Promise<Template[]>;
10
15
  categories: () => Promise<string[]>;
11
16
  search: (keyword: string, cateName?: string) => Promise<Template[]>;
12
17
  extend(manager: import("./template-type.js").TemplateManager): void;
13
18
  };
19
+ /** What THIS edgeless offers: the global half plus its std's categories. */
20
+ get catalogue(): import("./template-type.js").TemplateManager;
14
21
  private _fetchJob;
15
22
  draggableController: EdgelessDraggableElementController<Template>;
16
23
  private _closePanel;
@@ -45,6 +45,7 @@ import { property, state } from 'lit/decorators.js';
45
45
  import { repeat } from 'lit/directives/repeat.js';
46
46
  import { styleMap } from 'lit/directives/style-map.js';
47
47
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
48
+ import { templateManagerFor } from '../contribute.js';
48
49
  import { createTemplateJob } from '../services/template.js';
49
50
  import { builtInTemplates } from './builtin-templates.js';
50
51
  import { defaultPreview, Triangle } from './cards.js';
@@ -261,7 +262,16 @@ let EdgelessTemplatePanel = (() => {
261
262
  color: var(--affine-background-overlay-panel-color);
262
263
  }
263
264
  `; }
265
+ /**
266
+ * The GLOBAL half of the catalogue — `Other` and a host's `extend(...)`.
267
+ * Framework categories are not here: they live in the editor's DI container
268
+ * (`TemplateCategoryExtension`) and are read through {@link catalogue}.
269
+ */
264
270
  static { this.templates = builtInTemplates; }
271
+ /** What THIS edgeless offers: the global half plus its std's categories. */
272
+ get catalogue() {
273
+ return templateManagerFor(this.edgeless.std);
274
+ }
265
275
  _closePanel() {
266
276
  if (this.isDragging)
267
277
  return;
@@ -293,9 +303,14 @@ let EdgelessTemplatePanel = (() => {
293
303
  }
294
304
  async _initCategory() {
295
305
  try {
296
- this._categories = await EdgelessTemplatePanel.templates.categories();
306
+ this._categories = await this.catalogue.categories();
307
+ // The remembered tab may belong to a framework this editor no longer
308
+ // registers (switched off since): fall back rather than show an empty list.
309
+ const remembered = this._getLocalSelectedCategory();
297
310
  this._currentCategory =
298
- this._getLocalSelectedCategory() ?? this._categories[0];
311
+ remembered && this._categories.includes(remembered)
312
+ ? remembered
313
+ : this._categories[0];
299
314
  this._updateTemplates();
300
315
  }
301
316
  catch (e) {
@@ -366,8 +381,8 @@ let EdgelessTemplatePanel = (() => {
366
381
  this._fetch(async (state) => {
367
382
  try {
368
383
  const templates = this._searchKeyword
369
- ? await EdgelessTemplatePanel.templates.search(this._searchKeyword)
370
- : await EdgelessTemplatePanel.templates.list(this._currentCategory);
384
+ ? await this.catalogue.search(this._searchKeyword)
385
+ : await this.catalogue.list(this._currentCategory);
371
386
  if (state.canceled)
372
387
  return;
373
388
  this._templates = templates;
@@ -57,7 +57,16 @@ function isNodeType(node) {
57
57
  }
58
58
  function observeChildren(_, instance, transaction) {
59
59
  if (instance.children.doc) {
60
- instance.setChildIds(Array.from(instance.children.keys()), transaction?.local ?? true);
60
+ // `?? false`, like every other group-like element (see `group.ts`): a
61
+ // missing transaction is NOT a local gesture. `startObserve` re-runs this
62
+ // observer with `transaction === null` whenever the `children` key itself
63
+ // is rewritten — by a remote peer, a snapshot import or an undo — and
64
+ // reporting that as local makes the group watchers treat a received change
65
+ // as one this peer just made: they delete an emptied mindmap locally,
66
+ // which a readonly viewer cannot do (#251, rule from #242).
67
+ instance.setChildIds(Array.from(instance.children.keys()), transaction?.local ?? false);
68
+ // Pure local recompute, no CRDT write: unconditional on purpose, every
69
+ // peer must rebuild its tree to repaint.
61
70
  instance.buildTree();
62
71
  instance.connectors.clear();
63
72
  }
@@ -7,6 +7,13 @@ import { html, nothing } from 'lit';
7
7
  import { literal, unsafeStatic } from 'lit/static-html.js';
8
8
  import { defaultKeyboardToolbarConfig, KeyboardToolbarConfigExtension, } from './config.js';
9
9
  export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
10
+ /**
11
+ * Signals backing the fallback keyboard, at module level on purpose: the
12
+ * `keyboard` getter is read from `render()`, so a fresh `signal()` per call
13
+ * would hand `PositionController`'s effect a brand new object every frame.
14
+ */
15
+ const fallbackKeyboardVisible$ = signal(false);
16
+ const fallbackKeyboardHeight$ = signal(0);
10
17
  export class AffineKeyboardToolbarWidget extends WidgetComponent {
11
18
  constructor() {
12
19
  super(...arguments);
@@ -26,12 +33,20 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent {
26
33
  this._initialInputMode = '';
27
34
  }
28
35
  get keyboard() {
29
- const provider = this.std.get(VirtualKeyboardProvider);
30
- if (isVirtualKeyboardProviderWithAction(provider))
36
+ // The provider is host-supplied — a native mobile shell reports the
37
+ // on-screen keyboard. Labre ships no such shell, so a plain web host
38
+ // registers nothing and this lookup must stay optional; a hard `get` throws
39
+ // and takes the whole document open down with it (issue #247).
40
+ const provider = this.std.getOptional(VirtualKeyboardProvider);
41
+ if (provider && isVirtualKeyboardProviderWithAction(provider))
31
42
  return provider;
32
43
  return {
33
- // fallback keyboard actions
44
+ // Fallback keyboard, complete WITHOUT a provider: the signals come first
45
+ // so that a provider without actions still wins on them through the
46
+ // spread below, while its `show`/`hide` stay ours.
34
47
  fallback: true,
48
+ visible$: fallbackKeyboardVisible$,
49
+ height$: fallbackKeyboardHeight$,
35
50
  show: () => {
36
51
  const rootComponent = this.block?.rootComponent;
37
52
  if (rootComponent && rootComponent === document.activeElement) {
@@ -13,7 +13,13 @@ export declare class AffineMobileLinkedDocMenu extends AffineMobileLinkedDocMenu
13
13
  private readonly _updateLinkedDocGroup;
14
14
  private _updateLinkedDocGroupAbortController;
15
15
  private get _query();
16
- get keyboard(): VirtualKeyboardProvider | import("../affine-shared/services/virtual-keyboard-service.js").VirtualKeyboardProviderWithAction;
16
+ /**
17
+ * The virtual keyboard provider is host-supplied — a native mobile shell
18
+ * reports the on-screen keyboard. Labre ships no such shell, so a plain web
19
+ * host registers nothing and this lookup must stay optional; a hard `get`
20
+ * throws and takes the whole document open down with it (issue #247).
21
+ */
22
+ get keyboard(): VirtualKeyboardProvider | null;
17
23
  connectedCallback(): void;
18
24
  firstUpdated(): void;
19
25
  render(): import("lit-html").TemplateResult<1> | typeof nothing;
@@ -71,8 +71,14 @@ let AffineMobileLinkedDocMenu = (() => {
71
71
  get _query() {
72
72
  return getTextContentFromInlineRange(this.context.inlineEditor, this.context.startRange);
73
73
  }
74
+ /**
75
+ * The virtual keyboard provider is host-supplied — a native mobile shell
76
+ * reports the on-screen keyboard. Labre ships no such shell, so a plain web
77
+ * host registers nothing and this lookup must stay optional; a hard `get`
78
+ * throws and takes the whole document open down with it (issue #247).
79
+ */
74
80
  get keyboard() {
75
- return this.context.std.get(VirtualKeyboardProvider);
81
+ return this.context.std.getOptional(VirtualKeyboardProvider) ?? null;
76
82
  }
77
83
  connectedCallback() {
78
84
  super.connectedCallback();
@@ -114,7 +120,7 @@ let AffineMobileLinkedDocMenu = (() => {
114
120
  if (groups.length === 0) {
115
121
  return nothing;
116
122
  }
117
- this.style.bottom = `${this.keyboard.height$.value}px`;
123
+ this.style.bottom = `${this.keyboard?.height$.value ?? 0}px`;
118
124
  return html `
119
125
  ${join(groups.map(this._renderGroup), html `<div class="divider"></div>`)}
120
126
  `;
@@ -20,7 +20,7 @@ export { GfxLocalElementModel, prop, } from './model/surface/local-element-model
20
20
  export { collectPivotOccurrences, isPivotBound, type PivotBoundElement, type PivotOccurrence, resolvePivotBinding, } from './model/surface/pivot.js';
21
21
  export { type EdgeDirectionDef, findRoleDef, isTypedEdgeRole, type RoleDef, type RoleDefs, type RoleId, roleIsA, type RoleKind, RoleVocabularyExtension, RoleVocabularyIdentifier, } from './model/surface/role.js';
22
22
  export { ELEMENT_TAGS_FIELD, type ElementTags, elementTagValues, hasElementTagValue, readElementTags, setElementTag, tagsPropToY, } from './model/surface/tags.js';
23
- export { SURFACE_TEXT_UNIQ_IDENTIFIER, SURFACE_YMAP_UNIQ_IDENTIFIER, SurfaceBlockModel, type SurfaceBlockProps, type SurfaceMiddleware, } from './model/surface/surface-model.js';
23
+ export { type ElementUpdatedData, SURFACE_TEXT_UNIQ_IDENTIFIER, SURFACE_YMAP_UNIQ_IDENTIFIER, SurfaceBlockModel, type SurfaceBlockProps, type SurfaceMiddleware, } from './model/surface/surface-model.js';
24
24
  export { measureOperation } from './perf.js';
25
25
  export { GfxSelectionManager } from './selection.js';
26
26
  export { SurfaceMiddlewareBuilder, SurfaceMiddlewareExtension, } from './surface-middleware.js';
@@ -510,7 +510,10 @@ export class SurfaceBlockModel extends BlockModel {
510
510
  if (isGfxGroupCompatibleModel(payload.model)) {
511
511
  this._groupLikeModels.delete(payload.id);
512
512
  }
513
- {
513
+ // Same rule as `_watchGroupRelationChange`: the author already
514
+ // dropped the block from its group, and `removeChild` is a raw
515
+ // `transact` — on a readonly viewer it would be a silent write.
516
+ if (payload.isLocal) {
514
517
  const group = this.getGroup(payload.id);
515
518
  if (group) {
516
519
  // oxlint-disable-next-line unicorn/prefer-dom-node-remove
@@ -563,7 +566,14 @@ export class SurfaceBlockModel extends BlockModel {
563
566
  }
564
567
  _watchGroupRelationChange() {
565
568
  const isGroup = (element) => element instanceof GfxGroupLikeElementModel;
566
- const disposable = this.elementUpdated.subscribe(({ id, oldValues }) => {
569
+ const disposable = this.elementUpdated.subscribe(({ id, oldValues, local }) => {
570
+ // A cascade belongs to the peer that made the change: the author's own
571
+ // watcher deletes the group it emptied, and that deletion arrives with
572
+ // the same sync. Reacting to a REMOTE update with a local write is
573
+ // redundant on a writeable peer and, on a readonly viewer, the
574
+ // production exception `Cannot remove element in readonly mode`.
575
+ if (!local)
576
+ return;
567
577
  const element = this.getElementById(id);
568
578
  if (isGroup(element) &&
569
579
  oldValues['childIds'] &&
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formicoidea/labre-core",
3
3
  "description": "Labre editor — the full BlockSuite-derived editor as one package.",
4
- "version": "0.38.1",
4
+ "version": "0.39.1",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "author": "lajola",