@threadlabs/looma 0.28.1 → 0.28.2

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.
@@ -0,0 +1,39 @@
1
+ import { Editor, type EditorOptions, type JSONContent } from "@tiptap/core";
2
+ import { type Selection, type SelectionBookmark } from "@tiptap/pm/state";
3
+ /** A selection remembered so a controlled document replacement can put it back. */
4
+ export interface LoomaSelectionSnapshot {
5
+ bookmark: SelectionBookmark;
6
+ from: number;
7
+ to: number;
8
+ text: boolean;
9
+ }
10
+ export declare function selectionSnapshot(selection: Selection): LoomaSelectionSnapshot;
11
+ /**
12
+ * One editor's Tiptap instance, independent of any component framework.
13
+ *
14
+ * @ownership The session owns the `Editor` from creation until `destroy()`. A host renders an
15
+ * element for the document and hands it to `attach()`; it never creates or destroys the editor.
16
+ * @lifecycle The editor, its document, and its undo history survive `attach()` to a new element,
17
+ * so a host can remount its view without losing state.
18
+ */
19
+ export interface LoomaEditorSession<E extends Editor = Editor> {
20
+ readonly editor: E;
21
+ /** Moves the editor's view into `element` (idempotent). */
22
+ attach(element: HTMLElement): void;
23
+ /**
24
+ * Applies a controlled document.
25
+ *
26
+ * @contract Returns false, and leaves the editor untouched, when the document already matches.
27
+ * A focused editor keeps `selection` (or its current selection) in the same transaction.
28
+ * @failure A selection the new document cannot hold becomes the closest valid text range.
29
+ */
30
+ setContent(content: JSONContent, selection?: LoomaSelectionSnapshot | null): boolean;
31
+ /** Calls `listener` after every transaction; returns the unsubscribe function. */
32
+ subscribe(listener: () => void): () => void;
33
+ destroy(): void;
34
+ }
35
+ /**
36
+ * Creates the session's editor from `options`. A framework layer may pass its own `Editor`
37
+ * subclass, such as `@tiptap/vue-3`'s reactive one, so the editor it exposes keeps that behavior.
38
+ */
39
+ export declare function createEditorSession<E extends Editor = Editor>(options: Partial<EditorOptions>, EditorClass?: new (options: Partial<EditorOptions>) => E): LoomaEditorSession<E>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
2
2
  import { Extension, Mark, Node, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
3
- import { Editor, EditorContent } from "@tiptap/vue-3";
3
+ import { Editor as Editor$1, EditorContent } from "@tiptap/vue-3";
4
4
  import { closeHistory, history, redo, undo } from "@tiptap/pm/history";
5
5
  import { Plugin } from "@tiptap/pm/state";
6
6
  //#region ../../node_modules/.pnpm/@tiptap+extension-document@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2_/node_modules/@tiptap/extension-document/dist/index.js
@@ -498,7 +498,7 @@ var LoomaCompactEditor = defineComponent({
498
498
  setup(props, { attrs, emit, expose }) {
499
499
  const editor = shallowRef();
500
500
  onMounted(() => {
501
- editor.value = new Editor({
501
+ editor.value = new Editor$1({
502
502
  extensions: [...getCompactEditorExtensions(), ...props.extensions],
503
503
  content: props.modelValue,
504
504
  editable: props.editable,
@@ -1,8 +1,8 @@
1
1
  import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useId, watch } from "vue";
2
2
  import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig, resolveTableCellAt, siteRelativeHref } from "@threadlabs/looma/editor";
3
3
  import { Button, Callout, Card, Checkbox, Cluster, Combobox, Disclosure, EditorInsertTableGrid, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorSurface, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, EditorToolbar as EditorToolbar$1, FormField, Icon, IconButton, Image, Input, InputGroup, List, Menu, MenuItem, Popover, ScrollArea, SearchResultRow, Separator, Stack, Text, Tooltip } from "@threadlabs/looma/vue";
4
- import { Extension, Node as Node$1, getText, getTextSerializersFromSchema, mergeAttributes, nodeInputRule, posToDOMRect } from "@tiptap/core";
5
- import { BubbleMenu, EditorContent, useEditor } from "@tiptap/vue-3";
4
+ import { Editor, Extension, Node as Node$1, getText, getTextSerializersFromSchema, mergeAttributes, nodeInputRule, posToDOMRect } from "@tiptap/core";
5
+ import { BubbleMenu, Editor as Editor$1 } from "@tiptap/vue-3";
6
6
  import { closeHistory } from "@tiptap/pm/history";
7
7
  import { AllSelection, NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
8
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
@@ -737,6 +737,63 @@ function selectedTableCellElement(editor) {
737
737
  return selectionElement(editor)?.closest("td, th");
738
738
  }
739
739
  //#endregion
740
+ //#region src/editor/session.ts
741
+ function selectionSnapshot(selection) {
742
+ return {
743
+ bookmark: selection.getBookmark(),
744
+ from: selection.from,
745
+ to: selection.to,
746
+ text: selection instanceof TextSelection
747
+ };
748
+ }
749
+ /**
750
+ * Creates the session's editor from `options`. A framework layer may pass its own `Editor`
751
+ * subclass, such as `@tiptap/vue-3`'s reactive one, so the editor it exposes keeps that behavior.
752
+ */
753
+ function createEditorSession(options, EditorClass = Editor) {
754
+ const editor = new EditorClass(options);
755
+ return {
756
+ editor,
757
+ attach(element) {
758
+ const current = editor.options.element;
759
+ if (editor.isDestroyed || current === element || !current.firstChild) return;
760
+ element.append(...current.childNodes);
761
+ editor.setOptions({ element });
762
+ editor.createNodeViews();
763
+ },
764
+ setContent(content, selection) {
765
+ if (editor.isDestroyed || sameDocument(editor.getJSON(), content)) return false;
766
+ const previous = selection ?? selectionSnapshot(editor.view.state.selection);
767
+ if (!editor.isFocused) {
768
+ editor.commands.setContent(content, false);
769
+ return true;
770
+ }
771
+ editor.chain().setContent(content, false).command(({ tr }) => {
772
+ try {
773
+ tr.setSelection(previous.text ? TextSelection.create(tr.doc, previous.from, previous.to) : previous.bookmark.resolve(tr.doc));
774
+ } catch {
775
+ const max = tr.doc.content.size;
776
+ const from = Math.max(1, Math.min(previous.from, max));
777
+ const to = Math.max(from, Math.min(previous.to, max));
778
+ tr.setSelection(TextSelection.create(tr.doc, from, to));
779
+ }
780
+ return true;
781
+ }).run();
782
+ editor.view.focus();
783
+ return true;
784
+ },
785
+ subscribe(listener) {
786
+ editor.on("transaction", listener);
787
+ return () => {
788
+ editor.off("transaction", listener);
789
+ };
790
+ },
791
+ destroy() {
792
+ editor.destroy();
793
+ }
794
+ };
795
+ }
796
+ //#endregion
740
797
  //#region src/editor/shortcuts.ts
741
798
  /**
742
799
  * Shows a key binding in ProseMirror notation ("Mod-Shift-8") the way the reader's platform writes
@@ -2206,13 +2263,7 @@ var LoomaEditor = defineComponent({
2206
2263
  let linkPressedSelection = null;
2207
2264
  const rememberSelection = (instance) => {
2208
2265
  if (!instance.isFocused) return;
2209
- const selection = instance.view.state.selection;
2210
- lastFocusedSelection = {
2211
- bookmark: selection.getBookmark(),
2212
- from: selection.from,
2213
- to: selection.to,
2214
- text: selection instanceof TextSelection
2215
- };
2266
+ lastFocusedSelection = selectionSnapshot(instance.view.state.selection);
2216
2267
  };
2217
2268
  const authorHighlight = !props.disableHighlight;
2218
2269
  const codeHighlighting = createCodeHighlighting(props.codeLanguages, () => {
@@ -2228,83 +2279,96 @@ var LoomaEditor = defineComponent({
2228
2279
  style: {}
2229
2280
  });
2230
2281
  const loadForCodeBlock = codeHighlighting.loadForCodeBlock;
2231
- const editor = useEditor({
2232
- extensions: [
2233
- ...getDefaultEditorExtensions({
2234
- placeholder: props.placeholder,
2235
- linkBaseUrl: props.linkBaseUrl,
2236
- mention: mentionExtension ?? false,
2237
- disableHighlight: props.disableHighlight,
2238
- codeLanguages: props.codeLanguages ?? void 0,
2239
- codeLowlight: codeHighlighting.lowlight,
2240
- tableOfContentsNodeView,
2241
- expandNodeView,
2242
- dividerNodeView,
2243
- calloutNodeView,
2244
- imageNodeView
2245
- }),
2246
- imageDelivery.extension,
2247
- slashExtension,
2248
- ...props.extensions
2249
- ],
2250
- content: props.modelValue,
2251
- editable: props.editable,
2252
- editorProps: {
2253
- attributes: {
2254
- role: "textbox",
2255
- "aria-multiline": "true",
2256
- "aria-label": props.label
2257
- },
2258
- handleKeyDown: (_view, event) => onFormattingKeyDown(event),
2259
- handleDOMEvents: { click: (view, event) => {
2260
- if (!(event.target instanceof Element) || event.button !== 0) return false;
2261
- const link = event.target.closest("a[href]");
2262
- if (!link || !view.dom.contains(link)) return false;
2263
- const href = validLinkHref(link.getAttribute("href") ?? "");
2264
- if (!href) return false;
2265
- if (isApplePlatform() ? event.metaKey : event.ctrlKey) {
2266
- event.preventDefault();
2267
- window.open(href, "_blank", "noopener,noreferrer");
2268
- return true;
2269
- }
2270
- dismissedFormattingSelection = null;
2271
- requestAnimationFrame(() => {
2272
- if (view.isDestroyed) return;
2273
- editorStateVersion.value += 1;
2274
- const instance = editor.value;
2275
- if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && !linkOpen.value) {
2276
- linkPopup?.setProps({ getReferenceClientRect: () => link.getBoundingClientRect() });
2277
- linkPopup?.show();
2282
+ const editor = shallowRef();
2283
+ let session;
2284
+ onMounted(() => {
2285
+ session = createEditorSession({
2286
+ extensions: [
2287
+ ...getDefaultEditorExtensions({
2288
+ placeholder: props.placeholder,
2289
+ linkBaseUrl: props.linkBaseUrl,
2290
+ mention: mentionExtension ?? false,
2291
+ disableHighlight: props.disableHighlight,
2292
+ codeLanguages: props.codeLanguages ?? void 0,
2293
+ codeLowlight: codeHighlighting.lowlight,
2294
+ tableOfContentsNodeView,
2295
+ expandNodeView,
2296
+ dividerNodeView,
2297
+ calloutNodeView,
2298
+ imageNodeView
2299
+ }),
2300
+ imageDelivery.extension,
2301
+ slashExtension,
2302
+ ...props.extensions
2303
+ ],
2304
+ content: props.modelValue,
2305
+ editable: props.editable,
2306
+ editorProps: {
2307
+ attributes: {
2308
+ role: "textbox",
2309
+ "aria-multiline": "true",
2310
+ "aria-label": props.label
2311
+ },
2312
+ handleKeyDown: (_view, event) => onFormattingKeyDown(event),
2313
+ handleDOMEvents: { click: (view, event) => {
2314
+ if (!(event.target instanceof Element) || event.button !== 0) return false;
2315
+ const link = event.target.closest("a[href]");
2316
+ if (!link || !view.dom.contains(link)) return false;
2317
+ const href = validLinkHref(link.getAttribute("href") ?? "");
2318
+ if (!href) return false;
2319
+ if (isApplePlatform() ? event.metaKey : event.ctrlKey) {
2320
+ event.preventDefault();
2321
+ window.open(href, "_blank", "noopener,noreferrer");
2322
+ return true;
2278
2323
  }
2324
+ dismissedFormattingSelection = null;
2325
+ requestAnimationFrame(() => {
2326
+ if (view.isDestroyed) return;
2327
+ editorStateVersion.value += 1;
2328
+ const instance = editor.value;
2329
+ if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && !linkOpen.value) {
2330
+ linkPopup?.setProps({ getReferenceClientRect: () => link.getBoundingClientRect() });
2331
+ linkPopup?.show();
2332
+ }
2333
+ });
2334
+ return false;
2335
+ } }
2336
+ },
2337
+ onCreate: ({ editor: instance }) => {
2338
+ emit("ready", instance);
2339
+ instance.state.doc.descendants((node) => {
2340
+ if (node.type.name === "codeBlock") loadForCodeBlock(instance, node.attrs.language, node.textContent);
2279
2341
  });
2280
- return false;
2281
- } }
2282
- },
2283
- onCreate: ({ editor: instance }) => {
2284
- emit("ready", instance);
2285
- instance.state.doc.descendants((node) => {
2286
- if (node.type.name === "codeBlock") loadForCodeBlock(instance, node.attrs.language, node.textContent);
2287
- });
2288
- },
2289
- onFocus: ({ editor: instance }) => rememberSelection(instance),
2290
- onSelectionUpdate: ({ editor: instance }) => {
2291
- rememberSelection(instance);
2292
- if (floatingFormattingMode()) {
2293
- const selection = instance.state.selection;
2294
- if (!dismissedFormattingSelection?.eq(selection)) dismissedFormattingSelection = null;
2295
- if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2296
- if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2342
+ },
2343
+ onFocus: ({ editor: instance }) => rememberSelection(instance),
2344
+ onSelectionUpdate: ({ editor: instance }) => {
2345
+ rememberSelection(instance);
2346
+ if (floatingFormattingMode()) {
2347
+ const selection = instance.state.selection;
2348
+ if (!dismissedFormattingSelection?.eq(selection)) dismissedFormattingSelection = null;
2349
+ if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2350
+ if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2351
+ }
2352
+ if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
2353
+ },
2354
+ onUpdate: ({ editor: instance }) => {
2355
+ const value = instance.getJSON();
2356
+ emit("update:modelValue", value);
2357
+ emit("update", value);
2297
2358
  }
2298
- if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
2299
- },
2300
- onTransaction: () => {
2359
+ }, Editor$1);
2360
+ session.subscribe(() => {
2301
2361
  editorStateVersion.value += 1;
2302
- },
2303
- onUpdate: ({ editor: instance }) => {
2304
- const value = instance.getJSON();
2305
- emit("update:modelValue", value);
2306
- emit("update", value);
2307
- }
2362
+ });
2363
+ editor.value = session.editor;
2364
+ });
2365
+ const attachDocument = (element) => {
2366
+ if (element instanceof HTMLElement) session?.attach(element);
2367
+ };
2368
+ onBeforeUnmount(() => {
2369
+ const element = session?.editor.options.element;
2370
+ element?.parentNode?.replaceChild(element.cloneNode(true), element);
2371
+ session?.destroy();
2308
2372
  });
2309
2373
  watch(() => {
2310
2374
  editorStateVersion.value;
@@ -2773,31 +2837,9 @@ var LoomaEditor = defineComponent({
2773
2837
  });
2774
2838
  watch(() => props.modelValue, (value) => {
2775
2839
  const instance = editor.value;
2776
- if (!instance || sameDocument(instance.getJSON(), value)) return;
2840
+ if (!instance || !session) return;
2777
2841
  const wasFocused = instance.isFocused;
2778
- const previousSelection = lastFocusedSelection ?? {
2779
- bookmark: instance.view.state.selection.getBookmark(),
2780
- from: instance.view.state.selection.from,
2781
- to: instance.view.state.selection.to,
2782
- text: instance.view.state.selection instanceof TextSelection
2783
- };
2784
- if (!wasFocused) {
2785
- instance.commands.setContent(value, false);
2786
- return;
2787
- }
2788
- instance.chain().setContent(value, false).command(({ tr }) => {
2789
- try {
2790
- tr.setSelection(previousSelection.text ? TextSelection.create(tr.doc, previousSelection.from, previousSelection.to) : previousSelection.bookmark.resolve(tr.doc));
2791
- } catch {
2792
- const max = tr.doc.content.size;
2793
- const from = Math.max(1, Math.min(previousSelection.from, max));
2794
- const to = Math.max(from, Math.min(previousSelection.to, max));
2795
- tr.setSelection(TextSelection.create(tr.doc, from, to));
2796
- }
2797
- return true;
2798
- }).run();
2799
- instance.view.focus();
2800
- rememberSelection(instance);
2842
+ if (session.setContent(value, lastFocusedSelection) && wasFocused) rememberSelection(instance);
2801
2843
  }, { deep: true });
2802
2844
  const closeTableUi = () => {
2803
2845
  tableUi.toolbarOpen = false;
@@ -3710,7 +3752,7 @@ var LoomaEditor = defineComponent({
3710
3752
  }
3711
3753
  }
3712
3754
  }, { default: () => h(ImageControls, { editor: instance }) }) : null,
3713
- instance ? h(EditorSurface, { density: props.editable ? "comfortable" : props.contentDensity }, () => h(EditorContent, { editor: instance })) : null,
3755
+ instance ? h(EditorSurface, { density: props.editable ? "comfortable" : props.contentDensity }, () => h("div", { ref: attachDocument })) : null,
3714
3756
  codeUi.open ? h("div", {
3715
3757
  ref: codeLanguageRef,
3716
3758
  class: "looma-editor__code-language",