@abraca/nuxt 2.9.0 → 2.11.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.
Files changed (70) hide show
  1. package/dist/module.d.mts +14 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -0
  4. package/dist/runtime/assets/editor.css +1 -1
  5. package/dist/runtime/components/AConnectionBadge.d.vue.ts +29 -0
  6. package/dist/runtime/components/AConnectionBadge.vue +79 -0
  7. package/dist/runtime/components/AConnectionBadge.vue.d.ts +29 -0
  8. package/dist/runtime/components/AEditor.d.vue.ts +2 -2
  9. package/dist/runtime/components/AEditor.vue +11 -1
  10. package/dist/runtime/components/AEditor.vue.d.ts +2 -2
  11. package/dist/runtime/components/AEncryptionModePicker.d.vue.ts +33 -0
  12. package/dist/runtime/components/AEncryptionModePicker.vue +211 -0
  13. package/dist/runtime/components/AEncryptionModePicker.vue.d.ts +33 -0
  14. package/dist/runtime/components/AModalShell.d.vue.ts +48 -0
  15. package/dist/runtime/components/AModalShell.vue +105 -0
  16. package/dist/runtime/components/AModalShell.vue.d.ts +48 -0
  17. package/dist/runtime/components/ANodePanel.d.vue.ts +8 -6
  18. package/dist/runtime/components/ANodePanel.vue +25 -0
  19. package/dist/runtime/components/ANodePanel.vue.d.ts +8 -6
  20. package/dist/runtime/components/ANodePanelHeader.d.vue.ts +20 -10
  21. package/dist/runtime/components/ANodePanelHeader.vue +17 -3
  22. package/dist/runtime/components/ANodePanelHeader.vue.d.ts +20 -10
  23. package/dist/runtime/components/ANodeSettingsPanel.d.vue.ts +2 -0
  24. package/dist/runtime/components/ANodeSettingsPanel.vue +21 -1
  25. package/dist/runtime/components/ANodeSettingsPanel.vue.d.ts +2 -0
  26. package/dist/runtime/components/ASnapshotPreviewModal.d.vue.ts +33 -0
  27. package/dist/runtime/components/ASnapshotPreviewModal.vue +430 -0
  28. package/dist/runtime/components/ASnapshotPreviewModal.vue.d.ts +33 -0
  29. package/dist/runtime/components/docs/ADocsSearch.d.vue.ts +2 -2
  30. package/dist/runtime/components/docs/ADocsSearch.vue.d.ts +2 -2
  31. package/dist/runtime/components/editor/ALocationPickerPopover.vue +28 -7
  32. package/dist/runtime/components/registry/APluginDetail.d.vue.ts +2 -2
  33. package/dist/runtime/components/registry/APluginDetail.vue.d.ts +2 -2
  34. package/dist/runtime/components/renderers/AProseRenderer.d.vue.ts +2 -2
  35. package/dist/runtime/components/renderers/AProseRenderer.vue.d.ts +2 -2
  36. package/dist/runtime/components/shell/ABreadcrumbForDoc.d.vue.ts +6 -0
  37. package/dist/runtime/components/shell/ABreadcrumbForDoc.vue +75 -3
  38. package/dist/runtime/components/shell/ABreadcrumbForDoc.vue.d.ts +6 -0
  39. package/dist/runtime/components/shell/ADocPanelServerSettings.d.vue.ts +17 -0
  40. package/dist/runtime/components/shell/ADocPanelServerSettings.vue +253 -0
  41. package/dist/runtime/components/shell/ADocPanelServerSettings.vue.d.ts +17 -0
  42. package/dist/runtime/components/shell/ADocPanelSettings.d.vue.ts +2 -0
  43. package/dist/runtime/components/shell/ADocPanelSettings.vue +15 -4
  44. package/dist/runtime/components/shell/ADocPanelSettings.vue.d.ts +2 -0
  45. package/dist/runtime/components/shell/AUserMenu.d.vue.ts +2 -2
  46. package/dist/runtime/components/shell/AUserMenu.vue.d.ts +2 -2
  47. package/dist/runtime/composables/useDocBreadcrumb.d.ts +17 -2
  48. package/dist/runtime/composables/useDocBreadcrumb.js +17 -3
  49. package/dist/runtime/composables/useDocSnapshots.d.ts +2 -1
  50. package/dist/runtime/composables/useDocSnapshots.js +5 -0
  51. package/dist/runtime/composables/useEditor.d.ts +1 -1
  52. package/dist/runtime/composables/useEditor.js +120 -0
  53. package/dist/runtime/composables/useEditorToolbar.d.ts +12 -4
  54. package/dist/runtime/composables/useEditorToolbar.js +78 -56
  55. package/dist/runtime/composables/useNodeContextMenu.d.ts +10 -0
  56. package/dist/runtime/composables/useNodeContextMenu.js +41 -1
  57. package/dist/runtime/composables/useSwipeGesture.d.ts +48 -0
  58. package/dist/runtime/composables/useSwipeGesture.js +140 -0
  59. package/dist/runtime/extensions/document-header.js +16 -6
  60. package/dist/runtime/extensions/document-meta.js +344 -19
  61. package/dist/runtime/extensions/meta-field.js +42 -0
  62. package/dist/runtime/extensions/views/DocumentMetaView.vue +33 -7
  63. package/dist/runtime/extensions/views/FieldView.vue +51 -19
  64. package/dist/runtime/extensions/views/MetaFieldView.vue +30 -4
  65. package/dist/runtime/middleware/abracadabra-auth.d.ts +1 -1
  66. package/dist/runtime/plugin-abracadabra.client.d.ts +1 -1
  67. package/dist/runtime/plugin-abracadabra.client.js +12 -2
  68. package/dist/runtime/plugin-abracadabra.server.d.ts +1 -1
  69. package/dist/runtime/plugin-shared-globals.client.d.ts +1 -1
  70. package/package.json +1 -4
@@ -1,11 +1,15 @@
1
1
  import { Node } from "@tiptap/core";
2
- import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
3
2
  import { VueNodeViewRenderer } from "@tiptap/vue-3";
3
+ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
4
+ import { Fragment } from "@tiptap/pm/model";
5
+ import { DecorationSet, Decoration } from "@tiptap/pm/view";
6
+ import { isChangeOrigin } from "@tiptap/extension-collaboration";
4
7
  import DocumentMetaView from "./views/DocumentMetaView.vue";
5
8
  export const DocumentMeta = Node.create({
6
9
  name: "documentMeta",
7
10
  content: "inline*",
8
11
  defining: true,
12
+ draggable: false,
9
13
  parseHTML() {
10
14
  return [{ tag: 'div[data-type="document-meta"]' }];
11
15
  },
@@ -56,6 +60,263 @@ export const DocumentMeta = Node.create({
56
60
  },
57
61
  addProseMirrorPlugins() {
58
62
  return [
63
+ // ── L2: comprehensive structure guard ────────────────────────────────
64
+ // Rejects local transactions that would violate the
65
+ // documentHeader@0 documentMeta@1 block+
66
+ // invariant, and reshapes any post-state (including y-sync deltas
67
+ // that slipped past the Y-level enforcer) so we never render a
68
+ // damaged structure. Also enforces L6: documentMeta content is
69
+ // limited to metaField inline atoms.
70
+ new Plugin({
71
+ key: new PluginKey("documentStructureGuard"),
72
+ filterTransaction(tr) {
73
+ if (!tr.docChanged) return true;
74
+ if (isChangeOrigin(tr)) return true;
75
+ if (tr.getMeta("shape-repair")) return true;
76
+ const doc = tr.doc;
77
+ let headerCount = 0;
78
+ let headerIdx = -1;
79
+ let metaCount = 0;
80
+ let metaIdx = -1;
81
+ doc.forEach((node, _offset, index) => {
82
+ if (node.type.name === "documentHeader") {
83
+ headerCount++;
84
+ if (headerIdx === -1) headerIdx = index;
85
+ } else if (node.type.name === "documentMeta") {
86
+ metaCount++;
87
+ if (metaIdx === -1) metaIdx = index;
88
+ }
89
+ });
90
+ if (headerCount !== 1 || headerIdx !== 0) return false;
91
+ if (metaCount !== 1 || metaIdx !== 1) return false;
92
+ let strayMetaField = false;
93
+ doc.descendants((node, _pos, parent) => {
94
+ if (node.type.name === "metaField" && parent?.type.name !== "documentMeta") {
95
+ strayMetaField = true;
96
+ return false;
97
+ }
98
+ return true;
99
+ });
100
+ if (strayMetaField) return false;
101
+ return true;
102
+ },
103
+ // Repair damage that arrived via y-sync (or any tr the filter
104
+ // couldn't reject). Runs on every doc-changing tr so peers always
105
+ // see a healthy structure. We collapse duplicates by content, merge
106
+ // metaField children from extras into the canonical meta, and
107
+ // restore correct ordering.
108
+ appendTransaction(transactions, _oldState, newState) {
109
+ if (!transactions.some((t) => t.docChanged)) return null;
110
+ const doc = newState.doc;
111
+ const schema = newState.schema;
112
+ const headerType = schema.nodes.documentHeader;
113
+ const metaType = schema.nodes.documentMeta;
114
+ const paragraphType = schema.nodes.paragraph;
115
+ if (!headerType || !metaType || !paragraphType) return null;
116
+ const headers = [];
117
+ const metas = [];
118
+ const bodyContent = [];
119
+ const strayMetaFields = [];
120
+ doc.forEach((node, _offset, index) => {
121
+ if (node.type.name === "documentHeader")
122
+ headers.push({ node, idx: index, weight: node.textContent.length });
123
+ else if (node.type.name === "documentMeta")
124
+ metas.push({ node, idx: index, weight: node.childCount });
125
+ else bodyContent.push(node);
126
+ });
127
+ doc.descendants((node, _pos, parent) => {
128
+ if (node.type.name === "metaField" && parent?.type.name !== "documentMeta") {
129
+ strayMetaFields.push(node);
130
+ return false;
131
+ }
132
+ return true;
133
+ });
134
+ const pick = (arr) => {
135
+ if (arr.length === 0) return null;
136
+ let best = arr[0];
137
+ for (let i = 1; i < arr.length; i++) {
138
+ const c = arr[i];
139
+ if (c.weight > best.weight || c.weight === best.weight && c.idx < best.idx)
140
+ best = c;
141
+ }
142
+ return best;
143
+ };
144
+ const canonicalHeader = pick(headers);
145
+ const canonicalMeta = pick(metas);
146
+ const headerExtras = canonicalHeader ? headers.filter((h) => h !== canonicalHeader) : [];
147
+ const metaExtras = canonicalMeta ? metas.filter((m) => m !== canonicalMeta) : [];
148
+ const headerMissing = headers.length === 0;
149
+ const metaMissing = metas.length === 0;
150
+ const headerMisplaced = !headerMissing && (canonicalHeader?.idx ?? 0) !== 0;
151
+ const metaMisplaced = !metaMissing && (canonicalMeta?.idx ?? 0) !== 1;
152
+ const hasDuplicates = headerExtras.length > 0 || metaExtras.length > 0;
153
+ const hasStrayFields = strayMetaFields.length > 0;
154
+ if (!headerMissing && !metaMissing && !headerMisplaced && !metaMisplaced && !hasDuplicates && !hasStrayFields)
155
+ return null;
156
+ const repairedHeader = canonicalHeader?.node ?? headerType.create();
157
+ const identityOf = (mf) => {
158
+ const a = mf.attrs ?? {};
159
+ if (a.metaKey) return `mk:${a.metaKey}`;
160
+ const composite = `${a.startKey ?? ""}:${a.endKey ?? ""}:${a.latKey ?? ""}:${a.lngKey ?? ""}:${a.allDayKey ?? ""}`;
161
+ return composite === "::::" ? "" : `c:${composite}`;
162
+ };
163
+ const seen = /* @__PURE__ */ new Set();
164
+ const mergedFields = [];
165
+ const collectFields = (source) => {
166
+ source.forEach?.((child) => {
167
+ if (child.type.name !== "metaField") return;
168
+ const id = identityOf(child);
169
+ if (!id || seen.has(id)) return;
170
+ seen.add(id);
171
+ mergedFields.push(child);
172
+ });
173
+ };
174
+ if (canonicalMeta?.node) collectFields(canonicalMeta.node);
175
+ for (const m of metaExtras) collectFields(m.node);
176
+ for (const sf of strayMetaFields) {
177
+ const id = identityOf(sf);
178
+ if (!id || seen.has(id)) continue;
179
+ seen.add(id);
180
+ mergedFields.push(sf);
181
+ }
182
+ const metaNeedsRebuild = metaExtras.length > 0 || strayMetaFields.length > 0;
183
+ const repairedMeta = !metaNeedsRebuild && canonicalMeta?.node ? canonicalMeta.node : metaType.create(
184
+ null,
185
+ mergedFields.length > 0 ? Fragment.fromArray(mergedFields) : Fragment.empty
186
+ );
187
+ const finalBody = bodyContent.length > 0 ? bodyContent : [paragraphType.create()];
188
+ const newContent = Fragment.fromArray([
189
+ repairedHeader,
190
+ repairedMeta,
191
+ ...finalBody
192
+ ]);
193
+ const tr = newState.tr;
194
+ tr.replaceWith(0, doc.content.size, newContent);
195
+ tr.setMeta("shape-repair", true);
196
+ try {
197
+ const mappedAnchor = tr.mapping.map(newState.selection.anchor);
198
+ const mappedHead = tr.mapping.map(newState.selection.head);
199
+ tr.setSelection(
200
+ TextSelection.create(
201
+ tr.doc,
202
+ Math.min(mappedAnchor, tr.doc.content.size),
203
+ Math.min(mappedHead, tr.doc.content.size)
204
+ )
205
+ );
206
+ } catch {
207
+ const firstBodyPos = repairedHeader.nodeSize + repairedMeta.nodeSize + 1;
208
+ tr.setSelection(
209
+ TextSelection.create(
210
+ tr.doc,
211
+ Math.min(firstBodyPos, tr.doc.content.size)
212
+ )
213
+ );
214
+ }
215
+ return tr;
216
+ }
217
+ }),
218
+ // ── L5: selection-aware placeholder + cursor-in-meta DOM signal ──────
219
+ // Marks the documentMeta DOM node with:
220
+ // data-cursor-in-meta="true" when the selection is inside it
221
+ // data-empty="true" when its content is empty
222
+ // CSS uses the combination to render the placeholder hint only when
223
+ // BOTH are true — so an empty meta with the cursor elsewhere stays
224
+ // visually empty (but still clickable, per Layer 4), and a populated
225
+ // meta with cursor inside doesn't get a stray hint behind the chips.
226
+ new Plugin({
227
+ key: new PluginKey("documentMetaCursorSignal"),
228
+ props: {
229
+ decorations(state) {
230
+ let metaPos = -1;
231
+ let metaNode = null;
232
+ state.doc.forEach((node, offset) => {
233
+ if (metaPos === -1 && node.type.name === "documentMeta") {
234
+ metaPos = offset;
235
+ metaNode = node;
236
+ }
237
+ });
238
+ if (metaPos === -1 || !metaNode) return DecorationSet.empty;
239
+ const { $head } = state.selection;
240
+ let inMeta = false;
241
+ for (let d = $head.depth; d >= 0; d--) {
242
+ if ($head.node(d).type.name === "documentMeta") {
243
+ inMeta = true;
244
+ break;
245
+ }
246
+ }
247
+ const isEmpty = metaNode.content.size === 0;
248
+ if (!inMeta && !isEmpty) return DecorationSet.empty;
249
+ const attrs = {};
250
+ if (inMeta) attrs["data-cursor-in-meta"] = "true";
251
+ if (isEmpty) attrs["data-empty"] = "true";
252
+ return DecorationSet.create(state.doc, [
253
+ Decoration.node(metaPos, metaPos + metaNode.nodeSize, attrs)
254
+ ]);
255
+ }
256
+ }
257
+ }),
258
+ // ── L6: meta text-input lockout ──────────────────────────────────────
259
+ // Swallow all keystrokes inside documentMeta except '/' (which
260
+ // triggers the slash menu). Property chips remain insertable via
261
+ // the menu / programmatic commands. Combined with filterTransaction
262
+ // above, this guarantees nothing but metaField atoms can ever live
263
+ // in the meta header.
264
+ new Plugin({
265
+ key: new PluginKey("documentMetaInputLock"),
266
+ props: {
267
+ handleTextInput(view, _from, _to, text) {
268
+ const { $head } = view.state.selection;
269
+ let inMeta = false;
270
+ for (let d = $head.depth; d >= 0; d--) {
271
+ if ($head.node(d).type.name === "documentMeta") {
272
+ inMeta = true;
273
+ break;
274
+ }
275
+ }
276
+ if (!inMeta) return false;
277
+ return text !== "/";
278
+ }
279
+ }
280
+ }),
281
+ // ── L4 helper: explicit empty-meta click fallback ───────────────────
282
+ // With NodeViewContent using display:contents (required so chips
283
+ // align as direct flex children of the wrapper), PM's posAtCoords
284
+ // can't always find an inline position in an empty meta row.
285
+ // Catch any click that lands inside the meta wrapper but not on a
286
+ // chip / button / dropdown and place the cursor at the end of meta
287
+ // ourselves.
288
+ new Plugin({
289
+ key: new PluginKey("documentMetaClickFallback"),
290
+ props: {
291
+ handleClick(view, _pos, event) {
292
+ const target = event.target;
293
+ if (!target) return false;
294
+ const metaEl = target.closest('[data-type="document-meta"]');
295
+ if (!metaEl) return false;
296
+ if (target.closest('[data-type="meta-field"]')) return false;
297
+ if (target.closest("button")) return false;
298
+ if (target.closest('[role="menu"]')) return false;
299
+ if (target.closest("[data-popper-content]")) return false;
300
+ let metaPos = -1;
301
+ let metaContentSize = 0;
302
+ view.state.doc.forEach((node, offset) => {
303
+ if (metaPos === -1 && node.type.name === "documentMeta") {
304
+ metaPos = offset;
305
+ metaContentSize = node.content.size;
306
+ }
307
+ });
308
+ if (metaPos === -1) return false;
309
+ const targetPos = metaPos + 1 + metaContentSize;
310
+ view.dispatch(
311
+ view.state.tr.setSelection(
312
+ TextSelection.create(view.state.doc, targetPos)
313
+ )
314
+ );
315
+ view.focus();
316
+ return true;
317
+ }
318
+ }
319
+ }),
59
320
  new Plugin({
60
321
  key: new PluginKey("documentMetaGuard"),
61
322
  props: {
@@ -89,26 +350,90 @@ export const DocumentMeta = Node.create({
89
350
  return true;
90
351
  }
91
352
  return false;
353
+ },
354
+ // Block drops that target inside documentMeta or carry documentMeta content
355
+ handleDrop(view, event, slice, moved) {
356
+ if (event) {
357
+ const coords = { left: event.clientX, top: event.clientY };
358
+ const pos = view.posAtCoords(coords);
359
+ if (pos) {
360
+ const $pos = view.state.doc.resolve(pos.pos);
361
+ for (let d = $pos.depth; d >= 0; d--) {
362
+ if ($pos.node(d).type.name === "documentMeta") return true;
363
+ }
364
+ }
365
+ }
366
+ if (moved && slice) {
367
+ let hasMeta = false;
368
+ slice.content.forEach((node) => {
369
+ if (node.type.name === "documentMeta") hasMeta = true;
370
+ });
371
+ if (hasMeta) return true;
372
+ }
373
+ return false;
374
+ },
375
+ // Extract metaField nodes from pasted content and merge into documentMeta
376
+ handlePaste(view, _event, slice) {
377
+ const metaFieldNodes = [];
378
+ slice.content.forEach((node) => {
379
+ if (node.type.name === "metaField") {
380
+ metaFieldNodes.push(node);
381
+ } else if (node.type.name === "documentMeta") {
382
+ node.forEach((child) => {
383
+ if (child.type.name === "metaField") metaFieldNodes.push(child);
384
+ });
385
+ } else {
386
+ node.forEach((child) => {
387
+ if (child.type.name === "metaField") metaFieldNodes.push(child);
388
+ });
389
+ }
390
+ });
391
+ if (metaFieldNodes.length === 0) return false;
392
+ let metaPos = -1;
393
+ let metaNode = null;
394
+ view.state.doc.forEach((node, offset) => {
395
+ if (node.type.name === "documentMeta") {
396
+ metaPos = offset;
397
+ metaNode = node;
398
+ }
399
+ });
400
+ if (metaPos === -1 || !metaNode) return false;
401
+ const existingKeys = /* @__PURE__ */ new Set();
402
+ metaNode.forEach((child) => {
403
+ if (child.type.name === "metaField" && child.attrs.metaKey) {
404
+ existingKeys.add(`${child.attrs.fieldType}:${child.attrs.metaKey}`);
405
+ }
406
+ });
407
+ const newFields = metaFieldNodes.filter((n) => {
408
+ if (!n.attrs.metaKey) return true;
409
+ const key = `${n.attrs.fieldType}:${n.attrs.metaKey}`;
410
+ if (existingKeys.has(key)) return false;
411
+ existingKeys.add(key);
412
+ return true;
413
+ });
414
+ if (newFields.length > 0) {
415
+ const insertPos = metaPos + 1 + metaNode.content.size;
416
+ const tr = view.state.tr;
417
+ tr.insert(insertPos, Fragment.from(newFields));
418
+ view.dispatch(tr);
419
+ }
420
+ let hasNonMetaContent = false;
421
+ slice.content.forEach((node) => {
422
+ if (node.type.name !== "metaField" && node.type.name !== "documentMeta") {
423
+ if (node.type.name === "paragraph" || node.type.name === "heading") {
424
+ let hasNonFieldChild = false;
425
+ node.forEach((child) => {
426
+ if (child.type.name !== "metaField") hasNonFieldChild = true;
427
+ });
428
+ if (hasNonFieldChild || node.content.size === 0) hasNonMetaContent = true;
429
+ } else {
430
+ hasNonMetaContent = true;
431
+ }
432
+ }
433
+ });
434
+ return !hasNonMetaContent;
92
435
  }
93
436
  }
94
- }),
95
- new Plugin({
96
- key: new PluginKey("documentMetaDedupe"),
97
- appendTransaction(transactions, _old, newState) {
98
- if (!transactions.some((tr2) => tr2.docChanged)) return null;
99
- const metaPositions = [];
100
- newState.doc.forEach((node, offset) => {
101
- if (node.type.name === "documentMeta") metaPositions.push(offset);
102
- });
103
- if (metaPositions.length <= 1) return null;
104
- const tr = newState.tr;
105
- for (let i = metaPositions.length - 1; i >= 1; i--) {
106
- const pos = metaPositions[i];
107
- const node = newState.doc.nodeAt(pos);
108
- if (node) tr.delete(pos, pos + node.nodeSize);
109
- }
110
- return tr;
111
- }
112
437
  })
113
438
  ];
114
439
  }
@@ -66,6 +66,48 @@ export const MetaField = Node.create({
66
66
  const newNode = metaFieldType.create(attrs);
67
67
  if (dispatch) dispatch(tr.insert(insertPos, newNode));
68
68
  return true;
69
+ },
70
+ /**
71
+ * Move the metaField at `fromPos` to the index of the metaField currently
72
+ * at `toPos` (both are positions *inside* documentMeta — i.e. any position
73
+ * of the chip as reported by its NodeView's getPos()). No-op if the move
74
+ * would result in no change.
75
+ *
76
+ * MetaFieldView's drag-grip pointer handler calls this; without it the
77
+ * drag-reorder UI is rendered but inert.
78
+ */
79
+ moveMetaField: (fromPos, toPos) => ({ state, dispatch }) => {
80
+ const { doc, tr } = state;
81
+ let metaStart = -1;
82
+ let metaEnd = -1;
83
+ doc.forEach((node, offset) => {
84
+ if (node.type.name === "documentMeta") {
85
+ metaStart = offset + 1;
86
+ metaEnd = offset + 1 + node.content.size;
87
+ }
88
+ });
89
+ if (metaStart === -1) return false;
90
+ const fromNode = doc.nodeAt(fromPos);
91
+ if (!fromNode || fromNode.type.name !== "metaField") return false;
92
+ const fromFrom = fromPos;
93
+ const fromTo = fromPos + fromNode.nodeSize;
94
+ const target = Math.min(Math.max(toPos, metaStart), metaEnd);
95
+ let insertPos;
96
+ const toNode = doc.nodeAt(target);
97
+ if (toNode && toNode.type.name === "metaField") {
98
+ const targetStart = target;
99
+ const targetEnd = target + toNode.nodeSize;
100
+ const mid = (targetStart + targetEnd) / 2;
101
+ insertPos = toPos < mid ? targetStart : targetEnd;
102
+ } else {
103
+ insertPos = target;
104
+ }
105
+ if (insertPos === fromFrom || insertPos === fromTo) return false;
106
+ const newTr = tr.delete(fromFrom, fromTo);
107
+ const adjusted = insertPos > fromTo ? insertPos - fromNode.nodeSize : insertPos;
108
+ newTr.insert(adjusted, fromNode);
109
+ if (dispatch) dispatch(newTr);
110
+ return true;
69
111
  }
70
112
  };
71
113
  }
@@ -19,19 +19,51 @@ const items = computed(() => {
19
19
  void props.node.content.size;
20
20
  return buildMetaMenuItems(props.editor);
21
21
  });
22
+ function placeCursorInMeta(event) {
23
+ const target = event.target;
24
+ if (!target) return;
25
+ if (target.closest('[data-type="meta-field"]')) return;
26
+ if (target.closest("button")) return;
27
+ if (target.closest('[role="menu"]')) return;
28
+ if (target.closest("[data-popper-content]")) return;
29
+ const pos = props.getPos();
30
+ if (typeof pos !== "number") return;
31
+ const node = props.node;
32
+ const endPos = pos + 1 + node.content.size;
33
+ requestAnimationFrame(() => {
34
+ props.editor.chain().focus(endPos).run();
35
+ });
36
+ }
22
37
  </script>
23
38
 
24
39
  <template>
40
+ <!--
41
+ L4: clicks anywhere on the row that aren't on a chip / + button /
42
+ menu are routed to placeCursorInMeta so the cursor lands inside an
43
+ empty meta even when PM's posAtCoords would have missed it. The
44
+ row's CSS min-height in editor.css guarantees the row stays tall
45
+ enough to receive clicks.
46
+
47
+ L5: placeholder text is rendered by a CSS ::after pseudo on the
48
+ wrapper, gated on the `data-cursor-in-meta="true"` +
49
+ `data-empty="true"` attributes set by the documentMetaCursorSignal
50
+ decoration plugin (see extensions/document-meta.ts). It flows
51
+ inline after the + button so the two never overlap.
52
+ -->
25
53
  <NodeViewWrapper
26
54
  as="div"
27
55
  data-type="document-meta"
28
56
  class="group/meta"
29
- :class="{ 'meta-empty': node.content.size === 0 && !props.editor.isEditable }"
30
57
  draggable="false"
58
+ @mousedown="placeCursorInMeta"
31
59
  @dragstart.prevent.stop
32
60
  >
61
+ <!-- display: contents keeps chips as direct flex children of the
62
+ wrapper so they align with `align-items: center` / `gap` and
63
+ the cursor caret sits on the same baseline. -->
33
64
  <NodeViewContent
34
65
  as="span"
66
+ class="meta-content"
35
67
  style="display: contents"
36
68
  />
37
69
  <span
@@ -53,11 +85,5 @@ const items = computed(() => {
53
85
  />
54
86
  </UDropdownMenu>
55
87
  </span>
56
- <span
57
- v-if="node.content.size === 0 && props.editor.isEditable"
58
- class="text-sm text-(--ui-text-muted) pointer-events-none opacity-60"
59
- >
60
- Type '/' to add a property…
61
- </span>
62
88
  </NodeViewWrapper>
63
89
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
- import { NodeViewWrapper, NodeViewContent } from "@tiptap/vue-3";
3
2
  import { computed } from "vue";
3
+ import { NodeViewWrapper, NodeViewContent } from "@tiptap/vue-3";
4
+ import ANodeInlineLabel from "../../components/editor/ANodeInlineLabel.vue";
4
5
  const props = defineProps({
5
6
  decorations: { type: Array, required: true },
6
7
  selected: { type: Boolean, required: true },
@@ -14,9 +15,26 @@ const props = defineProps({
14
15
  extension: { type: Object, required: true },
15
16
  HTMLAttributes: { type: Object, required: true }
16
17
  });
17
- const name = computed(() => props.node.attrs.name || "field");
18
+ const FIELD_TYPES = ["string", "number", "boolean", "object", "array", "any"];
19
+ const name = computed(() => props.node.attrs.name || "");
18
20
  const fieldType = computed(() => props.node.attrs.type || "string");
19
- const required = computed(() => props.node.attrs.required);
21
+ const required = computed(() => !!props.node.attrs.required);
22
+ function setName(v) {
23
+ props.updateAttributes({ name: v });
24
+ }
25
+ function setType(v) {
26
+ props.updateAttributes({ type: v });
27
+ }
28
+ function toggleRequired() {
29
+ props.updateAttributes({ required: !required.value });
30
+ }
31
+ const typeItems = computed(
32
+ () => FIELD_TYPES.map((t) => ({
33
+ label: t,
34
+ onSelect: () => setType(t),
35
+ ...fieldType.value === t ? { icon: "i-lucide-check" } : {}
36
+ }))
37
+ );
20
38
  </script>
21
39
 
22
40
  <template>
@@ -28,22 +46,36 @@ const required = computed(() => props.node.attrs.required);
28
46
  class="flex items-center gap-3 font-mono text-sm"
29
47
  contenteditable="false"
30
48
  >
31
- <span
32
- v-if="name"
33
- class="font-semibold text-(--ui-primary)"
34
- >{{ name }}</span>
35
- <div
36
- v-if="fieldType || required"
37
- class="flex-1 flex items-center gap-1.5 text-xs"
38
- >
39
- <span
40
- v-if="fieldType"
41
- class="rounded-sm bg-(--ui-bg-elevated) text-(--ui-text-toned) px-1.5 py-0.5"
42
- >{{ fieldType }}</span>
43
- <span
44
- v-if="required"
45
- class="rounded-sm bg-red-500/10 text-(--ui-error) px-1.5 py-0.5"
46
- >required</span>
49
+ <ANodeInlineLabel
50
+ :model-value="name"
51
+ placeholder="fieldName"
52
+ variant="name"
53
+ @update:model-value="setName"
54
+ />
55
+ <div class="flex-1 flex items-center gap-1.5 text-xs">
56
+ <UDropdownMenu
57
+ :items="typeItems"
58
+ :content="{ align: 'start' }"
59
+ >
60
+ <button
61
+ class="rounded-sm bg-(--ui-bg-elevated) text-(--ui-text-toned) px-1.5 py-0.5 hover:bg-(--ui-bg-accented) inline-flex items-center gap-1"
62
+ title="Change type"
63
+ >
64
+ {{ fieldType }}
65
+ <UIcon
66
+ name="i-lucide-chevron-down"
67
+ class="size-3 opacity-60"
68
+ />
69
+ </button>
70
+ </UDropdownMenu>
71
+ <button
72
+ class="rounded-sm px-1.5 py-0.5 transition-colors"
73
+ :class="required ? 'bg-red-500/10 text-(--ui-error) hover:bg-red-500/20' : 'text-(--ui-text-muted) border border-dashed border-(--ui-border) hover:border-(--ui-border-accented) hover:text-(--ui-text)'"
74
+ :title="required ? 'Mark optional' : 'Mark required'"
75
+ @click="toggleRequired"
76
+ >
77
+ {{ required ? "required" : "optional" }}
78
+ </button>
47
79
  </div>
48
80
  </div>
49
81
  <div class="mt-3 text-(--ui-text-muted) text-sm [&_code]:text-xs/4">
@@ -75,6 +75,32 @@ function getObjArr(key) {
75
75
  if (!v || !Array.isArray(v)) return [];
76
76
  return v;
77
77
  }
78
+ function resolveOptionLabel(raw, opts) {
79
+ if (raw == null || raw === "") return "";
80
+ if (typeof raw === "string") {
81
+ if (!opts.length) return raw;
82
+ if (opts.includes(raw)) return raw;
83
+ if (/^-?\d+$/.test(raw)) {
84
+ const idx = Number(raw);
85
+ if (idx >= 0 && idx < opts.length) return opts[idx];
86
+ }
87
+ return raw;
88
+ }
89
+ if (typeof raw === "number" && Number.isFinite(raw)) {
90
+ if (raw >= 0 && raw < opts.length) return opts[raw];
91
+ return String(raw);
92
+ }
93
+ return String(raw);
94
+ }
95
+ const selectChipLabel = computed(
96
+ () => resolveOptionLabel(storage()?.pageMeta?.[metaKey.value], options.value)
97
+ );
98
+ const multiSelectChipValues = computed(() => {
99
+ const raw = storage()?.pageMeta?.[metaKey.value];
100
+ if (!Array.isArray(raw) || raw.length === 0) return [];
101
+ return raw.map((v) => resolveOptionLabel(v, options.value)).filter((s) => s.length > 0);
102
+ });
103
+ const multiSelectChipLabel = computed(() => multiSelectChipValues.value.join(", "));
78
104
  function getNum(key, fallback = 0) {
79
105
  return storage()?.pageMeta?.[key] ?? fallback;
80
106
  }
@@ -837,8 +863,8 @@ function removeOption(opt) {
837
863
  trailing
838
864
  class="h-7 px-2.5 border border-(--ui-border) rounded-md hover:bg-(--ui-bg-elevated) data-[state=open]:bg-(--ui-bg-elevated) min-w-28"
839
865
  >
840
- <span :class="getStr(metaKey) ? '' : 'text-(--ui-text-dimmed)'">
841
- {{ getStr(metaKey) || fieldLabel || t.select }}
866
+ <span :class="selectChipLabel ? '' : 'text-(--ui-text-dimmed)'">
867
+ {{ selectChipLabel || fieldLabel || t.select }}
842
868
  </span>
843
869
  </UButton>
844
870
  <template #content>
@@ -913,8 +939,8 @@ function removeOption(opt) {
913
939
  trailing
914
940
  class="h-7 px-2.5 border border-(--ui-border) rounded-md hover:bg-(--ui-bg-elevated) data-[state=open]:bg-(--ui-bg-elevated) min-w-28"
915
941
  >
916
- <span :class="getStrArr(metaKey).length ? '' : 'text-(--ui-text-dimmed)'">
917
- {{ getStrArr(metaKey).length ? getStrArr(metaKey).join(", ") : fieldLabel || t.select }}
942
+ <span :class="multiSelectChipValues.length ? '' : 'text-(--ui-text-dimmed)'">
943
+ {{ multiSelectChipValues.length ? multiSelectChipLabel : fieldLabel || t.select }}
918
944
  </span>
919
945
  </UButton>
920
946
  <template #content>
@@ -1,2 +1,2 @@
1
- declare const _default: any;
1
+ declare const _default: import("#app").RouteMiddleware;
2
2
  export default _default;
@@ -1,4 +1,4 @@
1
- declare const _default: any;
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
3
3
  declare module '#app' {
4
4
  interface NuxtApp {