@abraca/nuxt 2.10.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,4 +1,5 @@
1
1
  import { ref, shallowRef, watch, onUnmounted } from "vue";
2
+ import { Extension } from "@tiptap/core";
2
3
  import { XmlElement } from "yjs";
3
4
  import { useNuxtApp } from "#imports";
4
5
  export function useEditor(options) {
@@ -7,10 +8,21 @@ export function useEditor(options) {
7
8
  const connectedUsers = ref([]);
8
9
  const extensions = shallowRef([]);
9
10
  const ready = ref(false);
11
+ const activeColors = ref(/* @__PURE__ */ new Set());
12
+ const colorTimeouts = /* @__PURE__ */ new Map();
13
+ const CARET_ACTIVITY_TIMEOUT = 3e3;
10
14
  let cleanup = null;
15
+ let cursorBroadcastTimer = null;
11
16
  watch(childProvider, async (prov) => {
12
17
  cleanup?.();
13
18
  cleanup = null;
19
+ if (cursorBroadcastTimer) {
20
+ clearTimeout(cursorBroadcastTimer);
21
+ cursorBroadcastTimer = null;
22
+ }
23
+ colorTimeouts.forEach((t) => clearTimeout(t));
24
+ colorTimeouts.clear();
25
+ activeColors.value = /* @__PURE__ */ new Set();
14
26
  if (!prov) {
15
27
  ready.value = false;
16
28
  extensions.value = [];
@@ -34,6 +46,23 @@ export function useEditor(options) {
34
46
  CollaborationCaret.configure({
35
47
  provider: prov,
36
48
  user,
49
+ // Custom caret element: a thin coloured bar with a floating name label.
50
+ // Visibility is activity-gated — the `.is-active` class (added here when
51
+ // the author is in activeColors, and toggled live by the watcher below)
52
+ // is what makes the caret visible (editor.css defaults it to opacity:0).
53
+ render: (u) => {
54
+ const cursor = document.createElement("span");
55
+ cursor.classList.add("collaboration-carets__caret");
56
+ cursor.setAttribute("style", `border-color: ${u.color}`);
57
+ cursor.dataset.userColor = u.color;
58
+ const label = document.createElement("div");
59
+ label.classList.add("collaboration-carets__label");
60
+ label.setAttribute("style", `background-color: ${u.color}`);
61
+ label.insertBefore(document.createTextNode(u.name), null);
62
+ cursor.insertBefore(label, null);
63
+ if (activeColors.value.has(u.color)) cursor.classList.add("is-active");
64
+ return cursor;
65
+ },
37
66
  selectionRender: (u) => {
38
67
  const color = u.color || "#999";
39
68
  const selectionColor = color.startsWith("hsl") ? color.replace("hsl", "hsla").replace(")", ", 0.4)") : `${color}66`;
@@ -43,6 +72,20 @@ export function useEditor(options) {
43
72
  };
44
73
  }
45
74
  }),
75
+ // Broadcast the local cursor position to child awareness (throttled,
76
+ // 150ms trailing) so presence/follow consumers can track the caret
77
+ // without coupling to the CollaborationCaret extension internals.
78
+ Extension.create({
79
+ name: "cursor-position-broadcast",
80
+ onSelectionUpdate() {
81
+ const { from, to } = this.editor.state.selection;
82
+ if (cursorBroadcastTimer) clearTimeout(cursorBroadcastTimer);
83
+ cursorBroadcastTimer = setTimeout(() => {
84
+ cursorBroadcastTimer = null;
85
+ prov.awareness?.setLocalStateField("cursorPos", { from, to });
86
+ }, 150);
87
+ }
88
+ }),
46
89
  ...extraExtensions
47
90
  ];
48
91
  const ydoc = prov.document;
@@ -92,6 +135,13 @@ export function useEditor(options) {
92
135
  dedupeDocumentMeta();
93
136
  ensureDocumentMeta();
94
137
  }
138
+ const onProviderSynced = () => {
139
+ dedupeHeaders();
140
+ dedupeDocumentMeta();
141
+ ensureDocumentMeta();
142
+ };
143
+ prov.on?.("synced", onProviderSynced);
144
+ if (prov.isSynced) Promise.resolve().then(onProviderSynced);
95
145
  ready.value = true;
96
146
  const updateUsers = () => {
97
147
  const states = prov.awareness?.getStates() ?? /* @__PURE__ */ new Map();
@@ -104,14 +154,84 @@ export function useEditor(options) {
104
154
  });
105
155
  connectedUsers.value = users;
106
156
  };
157
+ const markActive = (color) => {
158
+ activeColors.value.add(color);
159
+ activeColors.value = new Set(activeColors.value);
160
+ if (colorTimeouts.has(color)) clearTimeout(colorTimeouts.get(color));
161
+ colorTimeouts.set(color, setTimeout(() => {
162
+ activeColors.value.delete(color);
163
+ activeColors.value = new Set(activeColors.value);
164
+ colorTimeouts.delete(color);
165
+ }, CARET_ACTIVITY_TIMEOUT));
166
+ };
167
+ const onDocUpdate = (_update, _origin, _doc, transaction) => {
168
+ const beforeState = transaction.beforeState;
169
+ const afterState = transaction.afterState;
170
+ if (!beforeState || !afterState) return;
171
+ const localClientId = ydoc.clientID;
172
+ const states = prov.awareness?.getStates() ?? /* @__PURE__ */ new Map();
173
+ for (const [clientId, clock] of afterState) {
174
+ if (clientId === localClientId) continue;
175
+ const beforeClock = beforeState.get(clientId) ?? 0;
176
+ if (clock > beforeClock) {
177
+ const state = states.get(clientId);
178
+ const color = state?.user?.color;
179
+ if (color) markActive(color);
180
+ }
181
+ }
182
+ };
183
+ ydoc?.on?.("update", onDocUpdate);
107
184
  prov.awareness?.on("change", updateUsers);
108
185
  updateUsers();
186
+ const fadeOutTimeouts = /* @__PURE__ */ new Map();
187
+ let caretSyncTimer = null;
188
+ const stopActiveWatch = watch(activeColors, (colors) => {
189
+ if (caretSyncTimer) clearTimeout(caretSyncTimer);
190
+ caretSyncTimer = setTimeout(() => {
191
+ caretSyncTimer = null;
192
+ const carets = document.querySelectorAll(".collaboration-carets__caret[data-user-color]");
193
+ carets.forEach((el) => {
194
+ const color = el.dataset.userColor;
195
+ if (!color) return;
196
+ if (colors.has(color)) {
197
+ if (fadeOutTimeouts.has(color)) {
198
+ clearTimeout(fadeOutTimeouts.get(color));
199
+ fadeOutTimeouts.delete(color);
200
+ }
201
+ el.classList.remove("is-hidden");
202
+ requestAnimationFrame(() => el.classList.add("is-active"));
203
+ } else {
204
+ el.classList.remove("is-active");
205
+ if (!fadeOutTimeouts.has(color)) {
206
+ fadeOutTimeouts.set(color, setTimeout(() => {
207
+ el.classList.add("is-hidden");
208
+ fadeOutTimeouts.delete(color);
209
+ }, 300));
210
+ }
211
+ }
212
+ });
213
+ }, 100);
214
+ });
109
215
  cleanup = () => {
216
+ ydoc?.off?.("update", onDocUpdate);
217
+ prov.off?.("synced", onProviderSynced);
110
218
  prov.awareness?.off("change", updateUsers);
219
+ stopActiveWatch();
220
+ if (caretSyncTimer) {
221
+ clearTimeout(caretSyncTimer);
222
+ caretSyncTimer = null;
223
+ }
224
+ fadeOutTimeouts.forEach((t) => clearTimeout(t));
225
+ fadeOutTimeouts.clear();
111
226
  if (fragment) {
112
227
  fragment.unobserve(dedupeHeaders);
113
228
  fragment.unobserve(dedupeDocumentMeta);
114
229
  }
230
+ if (cursorBroadcastTimer) {
231
+ clearTimeout(cursorBroadcastTimer);
232
+ cursorBroadcastTimer = null;
233
+ }
234
+ prov.awareness?.setLocalStateField("cursorPos", null);
115
235
  };
116
236
  }, { immediate: true });
117
237
  onUnmounted(() => cleanup?.());
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * useEditorToolbar
3
3
  *
4
- * Returns pre-built toolbar item groups for UEditorToolbar,
5
- * merging the plugin registry's contributions with standard formatting items.
4
+ * Returns pre-built toolbar item groups for UEditorToolbar, merging the plugin
5
+ * registry's contributions with standard formatting items.
6
+ *
7
+ * Three building blocks, all individually usable:
8
+ * - `items` full toolbar incl. undo/redo — for a docked/main toolbar.
9
+ * - `bubbleItems` selection bubble — same formatting groups WITHOUT undo/redo
10
+ * (undo/redo belongs in the panel header, not a text bubble).
11
+ * - `getTableToolbarItems(editor)` table-context actions (row/column/table ops).
6
12
  *
7
13
  * Usage:
8
- * const { items } = useEditorToolbar()
9
- * <UEditorToolbar :editor="editor" :items="items" />
14
+ * const { bubbleItems } = useEditorToolbar({ editor, docId })
15
+ * <UEditorToolbar :editor="editor" :items="bubbleItems" layout="bubble" />
10
16
  */
11
17
  import type { Editor } from '@tiptap/vue-3';
12
18
  export interface UseEditorToolbarOptions {
@@ -19,4 +25,6 @@ export interface UseEditorToolbarOptions {
19
25
  }
20
26
  export declare function useEditorToolbar(options?: UseEditorToolbarOptions): {
21
27
  items: import("vue").ComputedRef<any[][]>;
28
+ bubbleItems: import("vue").ComputedRef<any[][]>;
29
+ getTableToolbarItems: (editor: Editor) => any[][];
22
30
  };
@@ -1,72 +1,94 @@
1
1
  import { computed } from "vue";
2
2
  import { useAbracadabra } from "./useAbracadabra.js";
3
3
  import { usePluginRegistry } from "./usePluginRegistry.js";
4
+ const UNDO_REDO_GROUP = [
5
+ { kind: "undo", icon: "i-lucide-undo", tooltip: { text: "Undo" } },
6
+ { kind: "redo", icon: "i-lucide-redo", tooltip: { text: "Redo" } }
7
+ ];
8
+ const TURN_INTO_GROUP = [
9
+ {
10
+ label: "Turn into",
11
+ trailingIcon: "i-lucide-chevron-down",
12
+ activeColor: "neutral",
13
+ activeVariant: "ghost",
14
+ tooltip: { text: "Turn into" },
15
+ content: { align: "start" },
16
+ ui: { label: "text-xs" },
17
+ items: [
18
+ { type: "label", label: "Turn into" },
19
+ { kind: "paragraph", label: "Paragraph", icon: "i-lucide-type" },
20
+ { kind: "heading", level: 1, label: "Heading 1", icon: "i-lucide-heading-1" },
21
+ { kind: "heading", level: 2, label: "Heading 2", icon: "i-lucide-heading-2" },
22
+ { kind: "heading", level: 3, label: "Heading 3", icon: "i-lucide-heading-3" },
23
+ { kind: "bulletList", label: "Bullet List", icon: "i-lucide-list" },
24
+ { kind: "orderedList", label: "Ordered List", icon: "i-lucide-list-ordered" },
25
+ { kind: "taskList", label: "Task List", icon: "i-lucide-list-check" },
26
+ { kind: "blockquote", label: "Blockquote", icon: "i-lucide-text-quote" },
27
+ { kind: "codeBlock", label: "Code Block", icon: "i-lucide-square-code" }
28
+ ]
29
+ }
30
+ ];
31
+ const MARKS_GROUP = [
32
+ { kind: "mark", mark: "bold", icon: "i-lucide-bold", tooltip: { text: "Bold" } },
33
+ { kind: "mark", mark: "italic", icon: "i-lucide-italic", tooltip: { text: "Italic" } },
34
+ { kind: "mark", mark: "underline", icon: "i-lucide-underline", tooltip: { text: "Underline" } },
35
+ { kind: "mark", mark: "strike", icon: "i-lucide-strikethrough", tooltip: { text: "Strikethrough" } },
36
+ { kind: "mark", mark: "code", icon: "i-lucide-code", tooltip: { text: "Inline code" } }
37
+ ];
38
+ const SLOTS_GROUP = [
39
+ { slot: "link" },
40
+ { slot: "doc-link" },
41
+ { slot: "create-child-doc" },
42
+ { slot: "send-to-chat" }
43
+ ];
4
44
  export function useEditorToolbar(options = {}) {
5
45
  const registry = usePluginRegistry();
6
46
  const abra = useAbracadabra();
7
- const items = computed(() => {
8
- const editor = options.editor ?? null;
47
+ function pluginGroups() {
9
48
  const ctx = {
10
- editor,
49
+ editor: options.editor ?? null,
11
50
  docId: options.docId,
12
51
  abracadabra: abra
13
52
  };
14
- const base = [[
15
- { kind: "undo", icon: "i-lucide-undo", tooltip: { text: "Undo" } },
16
- { kind: "redo", icon: "i-lucide-redo", tooltip: { text: "Redo" } }
17
- ], [
18
- {
19
- label: "Turn into",
20
- trailingIcon: "i-lucide-chevron-down",
21
- activeColor: "neutral",
22
- activeVariant: "ghost",
23
- tooltip: { text: "Turn into" },
24
- content: { align: "start" },
25
- ui: { label: "text-xs" },
26
- items: [
27
- { type: "label", label: "Turn into" },
28
- { kind: "paragraph", label: "Paragraph", icon: "i-lucide-type" },
29
- { kind: "heading", level: 1, label: "Heading 1", icon: "i-lucide-heading-1" },
30
- { kind: "heading", level: 2, label: "Heading 2", icon: "i-lucide-heading-2" },
31
- { kind: "heading", level: 3, label: "Heading 3", icon: "i-lucide-heading-3" },
32
- { kind: "bulletList", label: "Bullet List", icon: "i-lucide-list" },
33
- { kind: "orderedList", label: "Ordered List", icon: "i-lucide-list-ordered" },
34
- { kind: "taskList", label: "Task List", icon: "i-lucide-list-check" },
35
- { kind: "blockquote", label: "Blockquote", icon: "i-lucide-text-quote" },
36
- { kind: "codeBlock", label: "Code Block", icon: "i-lucide-square-code" }
37
- ]
38
- }
39
- ], [
40
- { kind: "mark", mark: "bold", icon: "i-lucide-bold", tooltip: { text: "Bold" } },
41
- { kind: "mark", mark: "italic", icon: "i-lucide-italic", tooltip: { text: "Italic" } },
42
- { kind: "mark", mark: "underline", icon: "i-lucide-underline", tooltip: { text: "Underline" } },
43
- { kind: "mark", mark: "strike", icon: "i-lucide-strikethrough", tooltip: { text: "Strikethrough" } },
44
- { kind: "mark", mark: "code", icon: "i-lucide-code", tooltip: { text: "Inline code" } }
45
- ], [
46
- // Slot-based items — UEditorToolbar renders whatever the consumer
47
- // supplies via <template #link>, <template #doc-link>, etc. AEditor
48
- // provides defaults for `link` and `doc-link` (the popover primitives);
49
- // `create-child-doc` and `send-to-chat` render nothing unless the
50
- // consuming app fills the slot.
51
- { slot: "link" },
52
- { slot: "doc-link" },
53
- { slot: "create-child-doc" },
54
- { slot: "send-to-chat" }
55
- ]];
53
+ const out = [];
56
54
  try {
57
- const pluginGroups = registry.getAllToolbarItems(ctx);
58
- for (const group of pluginGroups) {
59
- if (group.items?.length) {
60
- base.push(group.items);
61
- }
55
+ const groups = registry.getAllToolbarItems(ctx);
56
+ for (const group of groups) {
57
+ if (group.items?.length) out.push(group.items);
62
58
  }
63
59
  } catch (e) {
64
60
  if (import.meta.dev) console.warn("[abracadabra] toolbar: failed to load plugin toolbar items:", e);
65
61
  }
66
- if (options.extraItems?.length) {
67
- base.push(...options.extraItems);
68
- }
69
- return base;
70
- });
71
- return { items };
62
+ return out;
63
+ }
64
+ const items = computed(() => [
65
+ UNDO_REDO_GROUP,
66
+ TURN_INTO_GROUP,
67
+ MARKS_GROUP,
68
+ SLOTS_GROUP,
69
+ ...pluginGroups(),
70
+ ...options.extraItems ?? []
71
+ ]);
72
+ const bubbleItems = computed(() => [
73
+ TURN_INTO_GROUP,
74
+ MARKS_GROUP,
75
+ SLOTS_GROUP,
76
+ ...pluginGroups(),
77
+ ...options.extraItems ?? []
78
+ ]);
79
+ function getTableToolbarItems(editor) {
80
+ const chain = () => editor.chain().focus();
81
+ return [[
82
+ { icon: "i-lucide-between-vertical-start", tooltip: { text: "Add row above" }, onClick: () => chain().addRowBefore().run() },
83
+ { icon: "i-lucide-between-vertical-end", tooltip: { text: "Add row below" }, onClick: () => chain().addRowAfter().run() },
84
+ { icon: "i-lucide-between-horizontal-start", tooltip: { text: "Add column before" }, onClick: () => chain().addColumnBefore().run() },
85
+ { icon: "i-lucide-between-horizontal-end", tooltip: { text: "Add column after" }, onClick: () => chain().addColumnAfter().run() }
86
+ ], [
87
+ { icon: "i-lucide-rows-3", tooltip: { text: "Delete row" }, onClick: () => chain().deleteRow().run() },
88
+ { icon: "i-lucide-columns-3", tooltip: { text: "Delete column" }, onClick: () => chain().deleteColumn().run() }
89
+ ], [
90
+ { icon: "i-lucide-trash", tooltip: { text: "Delete table" }, onClick: () => chain().deleteTable().run() }
91
+ ]];
92
+ }
93
+ return { items, bubbleItems, getTableToolbarItems };
72
94
  }
@@ -14,6 +14,16 @@ interface NodeContextMenuOptions {
14
14
  onNavigate?: (id: string) => void;
15
15
  onCopyLink?: (id: string) => void;
16
16
  onChangeType?: (id: string, newType: string) => void;
17
+ /** Open the doc — renders an "Open" item (e.g. navigate to the full page). */
18
+ onOpen?: (id: string) => void;
19
+ /** Open the doc in a floating window — renders "Open as window". */
20
+ onOpenInWindow?: (id: string) => void;
21
+ /** Create a child page under this node — renders "Add child page". */
22
+ onAddChild?: (parentId: string) => void;
23
+ /** Export the doc — renders an "Export" submenu (Markdown / HTML). Wire to
24
+ * `useDocExport().exportDoc` (kept out of this composable so it stays
25
+ * decoupled from the export pipeline + its lazy jszip import). */
26
+ onExport?: (id: string, format: 'markdown' | 'html') => void;
17
27
  favorites?: {
18
28
  isFavorite: (id: string) => boolean;
19
29
  toggleFavorite: (id: string) => void;
@@ -3,6 +3,25 @@ import { UI_COLORS } from "../types.js";
3
3
  export function useNodeContextMenu(opts) {
4
4
  const { nodeId, label: _label, type, tree, registry } = opts;
5
5
  const docTypes = getAvailableDocTypes(registry);
6
+ const openGroup = [];
7
+ if (opts.onOpen) {
8
+ openGroup.push({
9
+ label: "Open",
10
+ icon: "i-lucide-square-arrow-out-up-right",
11
+ onSelect() {
12
+ opts.onOpen(nodeId);
13
+ }
14
+ });
15
+ }
16
+ if (opts.onOpenInWindow) {
17
+ openGroup.push({
18
+ label: "Open as window",
19
+ icon: "i-lucide-app-window",
20
+ onSelect() {
21
+ opts.onOpenInWindow(nodeId);
22
+ }
23
+ });
24
+ }
6
25
  const editGroup = [
7
26
  {
8
27
  label: "Rename",
@@ -39,6 +58,15 @@ export function useNodeContextMenu(opts) {
39
58
  }))
40
59
  }
41
60
  ];
61
+ if (opts.onAddChild) {
62
+ editGroup.push({
63
+ label: "Add child page",
64
+ icon: "i-lucide-file-plus",
65
+ onSelect() {
66
+ opts.onAddChild(nodeId);
67
+ }
68
+ });
69
+ }
42
70
  const colorItems = UI_COLORS.slice(0, 12).map((colorName) => ({
43
71
  label: colorName.charAt(0).toUpperCase() + colorName.slice(1),
44
72
  icon: "i-lucide-circle",
@@ -82,6 +110,16 @@ export function useNodeContextMenu(opts) {
82
110
  }
83
111
  });
84
112
  }
113
+ if (opts.onExport) {
114
+ actionsGroup.push({
115
+ label: "Export",
116
+ icon: "i-lucide-download",
117
+ children: [
118
+ { label: "Markdown", icon: "i-lucide-file-text", onSelect: () => opts.onExport(nodeId, "markdown") },
119
+ { label: "HTML", icon: "i-lucide-file-code", onSelect: () => opts.onExport(nodeId, "html") }
120
+ ]
121
+ });
122
+ }
85
123
  const dangerGroup = [
86
124
  {
87
125
  label: "Move to trash",
@@ -92,7 +130,9 @@ export function useNodeContextMenu(opts) {
92
130
  }
93
131
  }
94
132
  ];
95
- const items = [editGroup, appearanceGroup];
133
+ const items = [];
134
+ if (openGroup.length > 0) items.push(openGroup);
135
+ items.push(editGroup, appearanceGroup);
96
136
  if (actionsGroup.length > 0) items.push(actionsGroup);
97
137
  items.push(dangerGroup);
98
138
  return { items };
@@ -0,0 +1,48 @@
1
+ import { type MaybeRefOrGetter, type Ref } from 'vue';
2
+ export interface SwipeGestureOptions {
3
+ /** Element to attach listeners to */
4
+ el: Ref<HTMLElement | null>;
5
+ /** Callback when user swipes left (navigate forward) */
6
+ onSwipeLeft?: () => void;
7
+ /** Callback when user swipes right (navigate back) */
8
+ onSwipeRight?: () => void;
9
+ /** Callback when user swipes up */
10
+ onSwipeUp?: () => void;
11
+ /** Callback when user swipes down */
12
+ onSwipeDown?: () => void;
13
+ /** Whether swiping left is currently valid (affects damping) */
14
+ canSwipeLeft?: MaybeRefOrGetter<boolean>;
15
+ /** Whether swiping right is currently valid (affects damping) */
16
+ canSwipeRight?: MaybeRefOrGetter<boolean>;
17
+ /** Whether swiping up is currently valid */
18
+ canSwipeUp?: MaybeRefOrGetter<boolean>;
19
+ /** Whether swiping down is currently valid */
20
+ canSwipeDown?: MaybeRefOrGetter<boolean>;
21
+ /** Disable swipe detection entirely */
22
+ disabled?: MaybeRefOrGetter<boolean>;
23
+ /** Restrict to a single axis. Gestures on the other axis pass through. */
24
+ axis?: 'x' | 'y';
25
+ }
26
+ /**
27
+ * Swipe gesture detection for both touch (mobile) and trackpad/wheel (desktop).
28
+ *
29
+ * Touch: locks axis after 10px, applies damped offset, fires on touchend.
30
+ * Wheel: accumulates deltaX/deltaY, fires after threshold, debounce resets.
31
+ *
32
+ * A child with `data-swipe-ignore` lets its own touch-scroll pass through.
33
+ *
34
+ * Ported 1:1 from cou-sh/app/composables/useSwipeGesture.ts.
35
+ */
36
+ export declare function useSwipeGesture(options: SwipeGestureOptions): {
37
+ swipeOffset: Ref<{
38
+ x: number;
39
+ y: number;
40
+ }, {
41
+ x: number;
42
+ y: number;
43
+ } | {
44
+ x: number;
45
+ y: number;
46
+ }>;
47
+ isSwiping: Ref<boolean, boolean>;
48
+ };
@@ -0,0 +1,140 @@
1
+ import { ref, toValue, watchEffect } from "vue";
2
+ export function useSwipeGesture(options) {
3
+ const swipeOffset = ref({ x: 0, y: 0 });
4
+ const isSwiping = ref(false);
5
+ let touchStartX = 0;
6
+ let touchStartY = 0;
7
+ let touchLockedAxis = null;
8
+ let touchIgnored = false;
9
+ const LOCK_THRESHOLD = 10;
10
+ const SWIPE_TRIGGER = 24;
11
+ const DAMP_VALID = 0.4;
12
+ const DAMP_INVALID = 0.1;
13
+ function isInsideSwipeIgnore(target, boundary) {
14
+ let el = target;
15
+ while (el && el !== boundary) {
16
+ if (el instanceof HTMLElement && el.hasAttribute("data-swipe-ignore")) return true;
17
+ el = el.parentElement;
18
+ }
19
+ return false;
20
+ }
21
+ function onTouchStart(e) {
22
+ if (toValue(options.disabled)) {
23
+ touchIgnored = true;
24
+ return;
25
+ }
26
+ if (options.el.value && isInsideSwipeIgnore(e.target, options.el.value)) {
27
+ touchIgnored = true;
28
+ return;
29
+ }
30
+ touchIgnored = false;
31
+ const touch = e.touches[0];
32
+ touchStartX = touch.clientX;
33
+ touchStartY = touch.clientY;
34
+ touchLockedAxis = null;
35
+ isSwiping.value = false;
36
+ swipeOffset.value = { x: 0, y: 0 };
37
+ }
38
+ function onTouchMove(e) {
39
+ if (touchIgnored) return;
40
+ const touch = e.touches[0];
41
+ const dx = touch.clientX - touchStartX;
42
+ const dy = touch.clientY - touchStartY;
43
+ if (!touchLockedAxis) {
44
+ if (Math.abs(dx) < LOCK_THRESHOLD && Math.abs(dy) < LOCK_THRESHOLD) return;
45
+ const detected = Math.abs(dx) >= Math.abs(dy) ? "x" : "y";
46
+ if (options.axis && detected !== options.axis) return;
47
+ touchLockedAxis = detected;
48
+ }
49
+ isSwiping.value = true;
50
+ if (touchLockedAxis === "x") {
51
+ const canL = toValue(options.canSwipeLeft) ?? !!options.onSwipeLeft;
52
+ const canR = toValue(options.canSwipeRight) ?? !!options.onSwipeRight;
53
+ const valid = dx < 0 && canL || dx > 0 && canR;
54
+ swipeOffset.value = { x: dx * (valid ? DAMP_VALID : DAMP_INVALID), y: 0 };
55
+ } else {
56
+ const canU = toValue(options.canSwipeUp) ?? !!options.onSwipeUp;
57
+ const canD = toValue(options.canSwipeDown) ?? !!options.onSwipeDown;
58
+ const valid = dy < 0 && canU || dy > 0 && canD;
59
+ swipeOffset.value = { x: 0, y: dy * (valid ? DAMP_VALID : DAMP_INVALID) };
60
+ }
61
+ e.preventDefault();
62
+ }
63
+ function onTouchEnd() {
64
+ if (touchIgnored) return;
65
+ if (touchLockedAxis === "x" && Math.abs(swipeOffset.value.x) > SWIPE_TRIGGER) {
66
+ if (swipeOffset.value.x < 0) options.onSwipeLeft?.();
67
+ else options.onSwipeRight?.();
68
+ } else if (touchLockedAxis === "y" && Math.abs(swipeOffset.value.y) > SWIPE_TRIGGER) {
69
+ if (swipeOffset.value.y < 0) options.onSwipeUp?.();
70
+ else options.onSwipeDown?.();
71
+ }
72
+ swipeOffset.value = { x: 0, y: 0 };
73
+ isSwiping.value = false;
74
+ touchLockedAxis = null;
75
+ }
76
+ let wheelAccumX = 0;
77
+ let wheelAccumY = 0;
78
+ let wheelTimer = null;
79
+ let wheelCooldown = false;
80
+ const WHEEL_THRESHOLD = 60;
81
+ function resetWheel() {
82
+ wheelAccumX = 0;
83
+ wheelAccumY = 0;
84
+ wheelCooldown = false;
85
+ wheelTimer = null;
86
+ }
87
+ function onWheel(e) {
88
+ if (toValue(options.disabled)) return;
89
+ if (e.ctrlKey) return;
90
+ wheelAccumX += e.deltaX;
91
+ wheelAccumY += e.deltaY;
92
+ if (wheelTimer) clearTimeout(wheelTimer);
93
+ wheelTimer = setTimeout(resetWheel, 300);
94
+ if (wheelCooldown) return;
95
+ if (Math.abs(wheelAccumX) > WHEEL_THRESHOLD && options.axis !== "y" && Math.abs(wheelAccumX) >= Math.abs(wheelAccumY)) {
96
+ if (wheelAccumX > 0) {
97
+ const canL = toValue(options.canSwipeLeft) ?? !!options.onSwipeLeft;
98
+ if (canL) options.onSwipeLeft?.();
99
+ } else {
100
+ const canR = toValue(options.canSwipeRight) ?? !!options.onSwipeRight;
101
+ if (canR) options.onSwipeRight?.();
102
+ }
103
+ wheelCooldown = true;
104
+ wheelAccumX = 0;
105
+ wheelAccumY = 0;
106
+ return;
107
+ }
108
+ if (Math.abs(wheelAccumY) > WHEEL_THRESHOLD && options.axis !== "x" && Math.abs(wheelAccumY) >= Math.abs(wheelAccumX)) {
109
+ if (wheelAccumY > 0) {
110
+ const canU = toValue(options.canSwipeUp) ?? !!options.onSwipeUp;
111
+ if (canU) options.onSwipeUp?.();
112
+ } else {
113
+ const canD = toValue(options.canSwipeDown) ?? !!options.onSwipeDown;
114
+ if (canD) options.onSwipeDown?.();
115
+ }
116
+ wheelCooldown = true;
117
+ wheelAccumX = 0;
118
+ wheelAccumY = 0;
119
+ }
120
+ }
121
+ watchEffect((onCleanup) => {
122
+ const el = options.el.value;
123
+ if (!el) return;
124
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
125
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
126
+ el.addEventListener("touchend", onTouchEnd, { passive: true });
127
+ el.addEventListener("touchcancel", onTouchEnd, { passive: true });
128
+ el.addEventListener("wheel", onWheel, { passive: true });
129
+ onCleanup(() => {
130
+ el.removeEventListener("touchstart", onTouchStart);
131
+ el.removeEventListener("touchmove", onTouchMove);
132
+ el.removeEventListener("touchend", onTouchEnd);
133
+ el.removeEventListener("touchcancel", onTouchEnd);
134
+ el.removeEventListener("wheel", onWheel);
135
+ if (wheelTimer) clearTimeout(wheelTimer);
136
+ resetWheel();
137
+ });
138
+ }, { flush: "post" });
139
+ return { swipeOffset, isSwiping };
140
+ }
@@ -18,14 +18,18 @@ export const DocumentHeader = Node.create({
18
18
  },
19
19
  addKeyboardShortcuts() {
20
20
  return {
21
- // Enter inside the header → move cursor to the first body block below
21
+ // Enter inside the header → skip past documentMeta to the first body block
22
22
  Enter: ({ editor }) => {
23
23
  const $head = editor.state.selection.$head;
24
24
  if ($head.parent.type.name !== "documentHeader") return false;
25
- const headerNode = editor.state.doc.firstChild;
26
- if (!headerNode) return false;
27
- const insideNextBlock = headerNode.nodeSize + 1;
28
- return editor.commands.setTextSelection(insideNextBlock);
25
+ let firstBodyPos = -1;
26
+ editor.state.doc.forEach((node, offset) => {
27
+ if (firstBodyPos === -1 && node.type.name !== "documentHeader" && node.type.name !== "documentMeta") {
28
+ firstBodyPos = offset + 1;
29
+ }
30
+ });
31
+ if (firstBodyPos > 0) return editor.commands.setTextSelection(firstBodyPos);
32
+ return false;
29
33
  },
30
34
  // Backspace at the very start of the header → swallow (don't delete the node)
31
35
  Backspace: ({ editor }) => {
@@ -47,10 +51,16 @@ export const DocumentHeader = Node.create({
47
51
  if (!(view.state.selection instanceof AllSelection)) return false;
48
52
  const { tr, schema } = view.state;
49
53
  const headerType = schema.nodes.documentHeader;
54
+ const metaType = schema.nodes.documentMeta;
50
55
  const paragraphType = schema.nodes.paragraph;
51
- if (!headerType || !paragraphType) return false;
56
+ if (!headerType || !metaType || !paragraphType) return false;
57
+ let existingMeta = null;
58
+ tr.doc.forEach((node) => {
59
+ if (node.type.name === "documentMeta") existingMeta = node;
60
+ });
52
61
  const newContent = Fragment.from([
53
62
  headerType.create(null, text ? [schema.text(text)] : []),
63
+ existingMeta ?? metaType.create(),
54
64
  paragraphType.create()
55
65
  ]);
56
66
  tr.replaceWith(0, tr.doc.content.size, newContent);