@threadlabs/looma 0.23.25 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.23.25",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import "./looma-editor.css";
2
- import type { AnyExtension, Editor, JSONContent } from "@tiptap/core";
2
+ import { type AnyExtension, type Editor, type JSONContent } from "@tiptap/core";
3
3
  import { type PropType, type VNode } from "vue";
4
4
  import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaMentionProvider, type LoomaCodeLanguages } from "@threadlabs/looma/editor";
5
5
  import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageRenditionErrorDetail } from "./image-delivery.js";
@@ -23,8 +23,9 @@ export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUplo
23
23
  /**
24
24
  * Chooses whether formatting controls follow a selection, occupy persistent
25
25
  * editor chrome, or open from an app-owned button (`popover`, with a text-only
26
- * bubble for selections). `contextual` exposes all commands at a focused caret
27
- * as well as a selection; it does not alter document commands or stored content.
26
+ * bubble for selections). Floating tools open on text selection or a held press
27
+ * and dismiss on Escape/caret movement. `contextual` includes the full command
28
+ * set. Narrow viewports retain a dock; document commands/content are unchanged.
28
29
  */
29
30
  export type LoomaEditorToolbarMode = "bubble" | "sticky" | "popover" | "contextual";
30
31
  /**
@@ -1,11 +1,11 @@
1
1
  import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useId, watch } from "vue";
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, shouldShowTextFormattingToolbar, siteRelativeHref } from "@threadlabs/looma/editor";
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, Card, Checkbox, Cluster, Combobox, Disclosure, EditorInsertTableGrid, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, 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";
4
5
  import { BubbleMenu, EditorContent, useEditor } from "@tiptap/vue-3";
5
6
  import { closeHistory } from "@tiptap/pm/history";
6
7
  import { createLowlight } from "lowlight";
7
8
  import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
- import { Extension, Node as Node$1, getText, getTextSerializersFromSchema, mergeAttributes, nodeInputRule } from "@tiptap/core";
9
9
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
10
10
  import { DOMParser, DOMSerializer, Fragment as Fragment$1 } from "@tiptap/pm/model";
11
11
  export * from "@threadlabs/looma/editor";
@@ -1795,6 +1795,21 @@ var LoomaEditor = defineComponent({
1795
1795
  const mobileToolbarStyle = ref({});
1796
1796
  const mobile = ref(typeof window !== "undefined" && window.innerWidth <= 767);
1797
1797
  const editorFocused = ref(false);
1798
+ let dismissedFormattingSelection = null;
1799
+ let heldFormattingSelection = null;
1800
+ let formattingPopup = null;
1801
+ let heldPressTimer;
1802
+ let heldPress = null;
1803
+ const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
1804
+ const cancelHeldPress = () => {
1805
+ clearTimeout(heldPressTimer);
1806
+ heldPressTimer = void 0;
1807
+ heldPress = null;
1808
+ };
1809
+ const formattingSelectionActive = (instance) => {
1810
+ const selection = instance.state.selection;
1811
+ return selection instanceof TextSelection && !dismissedFormattingSelection?.eq(selection) && (!selection.empty || Boolean(heldFormattingSelection?.eq(selection)));
1812
+ };
1798
1813
  const editorStateVersion = ref(0);
1799
1814
  const mobileToolbarMode = ref("formatting");
1800
1815
  let dragLeaveTimer = null;
@@ -1995,6 +2010,12 @@ var LoomaEditor = defineComponent({
1995
2010
  onFocus: ({ editor: instance }) => rememberSelection(instance),
1996
2011
  onSelectionUpdate: ({ editor: instance }) => {
1997
2012
  rememberSelection(instance);
2013
+ if (floatingFormattingMode()) {
2014
+ const selection = instance.state.selection;
2015
+ if (!dismissedFormattingSelection?.eq(selection)) dismissedFormattingSelection = null;
2016
+ if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2017
+ if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2018
+ }
1998
2019
  if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
1999
2020
  },
2000
2021
  onTransaction: () => {
@@ -2442,7 +2463,10 @@ var LoomaEditor = defineComponent({
2442
2463
  editor.value?.commands.dismissLoomaSlashMenu();
2443
2464
  });
2444
2465
  watch(() => props.editable, (editable) => {
2466
+ cancelHeldPress();
2467
+ heldFormattingSelection = null;
2445
2468
  editor.value?.setEditable(editable);
2469
+ if (!editable) formattingPopup?.hide();
2446
2470
  if (editor.value) imageDelivery.reset(editor.value);
2447
2471
  root.value?.querySelectorAll("[data-looma-chip]").forEach((chip) => {
2448
2472
  chip.setAttribute("aria-disabled", String(!editable));
@@ -2638,10 +2662,12 @@ var LoomaEditor = defineComponent({
2638
2662
  updateMobileViewport();
2639
2663
  };
2640
2664
  const onEditorBlur = () => {
2665
+ cancelHeldPress();
2641
2666
  queueMicrotask(updateTableUi);
2642
2667
  updateCodeUi();
2643
2668
  setTimeout(() => {
2644
2669
  if (!tableInteractionActive && !editor.value?.isFocused) editorFocused.value = false;
2670
+ if (!editor.value?.isFocused && !formattingPopup?.popper.contains(document.activeElement)) heldFormattingSelection = null;
2645
2671
  }, 0);
2646
2672
  };
2647
2673
  const bindEditorUi = (instance) => {
@@ -2667,9 +2693,13 @@ var LoomaEditor = defineComponent({
2667
2693
  }, { immediate: true });
2668
2694
  const onViewportChange = (event) => {
2669
2695
  if (event?.type === "scroll" && event.target instanceof Node && (tableToolbarShell.value?.contains(event.target) || tableMenuShell.value?.contains(event.target))) return;
2696
+ cancelHeldPress();
2697
+ const wasMobile = mobile.value;
2670
2698
  updateMobileViewport();
2699
+ if (wasMobile !== mobile.value) heldFormattingSelection = null;
2671
2700
  updateTableUi();
2672
2701
  updateCodeUi();
2702
+ if (floatingFormattingMode() && editor.value && !formattingSelectionActive(editor.value)) formattingPopup?.hide();
2673
2703
  tableUi.menuOpen = false;
2674
2704
  };
2675
2705
  const onDocumentPointerDown = (event) => {
@@ -2682,6 +2712,40 @@ var LoomaEditor = defineComponent({
2682
2712
  }, 0);
2683
2713
  if ((tableUi.toolbarOpen || tableUi.overlayOpen || tableUi.menuOpen) && !inElement(root.value) && !inTableUi) closeTableUi();
2684
2714
  };
2715
+ const onFormattingPointerDown = (event) => {
2716
+ cancelHeldPress();
2717
+ const instance = editor.value;
2718
+ if (!floatingFormattingMode() || tableResizeActive || !props.editable || !instance || event.button !== 0 || !event.isPrimary || !(event.target instanceof Element) || !instance.view.dom.contains(event.target) || event.target.closest("button, input, select, textarea, [data-looma-image-node]")) return;
2719
+ heldPress = {
2720
+ id: event.pointerId,
2721
+ x: event.clientX,
2722
+ y: event.clientY
2723
+ };
2724
+ heldPressTimer = setTimeout(() => {
2725
+ heldPressTimer = void 0;
2726
+ const selection = instance.state.selection;
2727
+ if (!heldPress || !floatingFormattingMode() || tableResizeActive || !props.editable || !instance.isFocused || slash.active || mention.active || !(selection instanceof TextSelection)) return;
2728
+ dismissedFormattingSelection = null;
2729
+ heldFormattingSelection = selection;
2730
+ formattingPopup?.setProps({ getReferenceClientRect: () => posToDOMRect(instance.view, selection.from, selection.to) });
2731
+ formattingPopup?.show();
2732
+ }, 500);
2733
+ };
2734
+ const onFormattingPointerMove = (event) => {
2735
+ if (heldPress?.id === event.pointerId && Math.hypot(event.clientX - heldPress.x, event.clientY - heldPress.y) > 8) cancelHeldPress();
2736
+ };
2737
+ const onFormattingPointerEnd = (event) => {
2738
+ if (heldPress?.id === event.pointerId) cancelHeldPress();
2739
+ };
2740
+ const onFormattingKeyDown = (event) => {
2741
+ cancelHeldPress();
2742
+ if (event.key !== "Escape" || !floatingFormattingMode() || !(event.target instanceof Node) || !(root.value?.contains(event.target) || formattingPopup?.popper.contains(event.target))) return;
2743
+ const selection = editor.value?.state.selection;
2744
+ dismissedFormattingSelection = selection instanceof TextSelection ? selection : null;
2745
+ heldFormattingSelection = null;
2746
+ formattingPopup?.hide();
2747
+ hideTool();
2748
+ };
2685
2749
  const onResizePointerDown = (event) => {
2686
2750
  tableResizeActive = event.target instanceof HTMLElement && event.target.classList.contains("column-resize-handle");
2687
2751
  };
@@ -2770,6 +2834,11 @@ var LoomaEditor = defineComponent({
2770
2834
  window.visualViewport?.addEventListener("resize", onViewportChange);
2771
2835
  window.visualViewport?.addEventListener("scroll", onViewportChange);
2772
2836
  document.addEventListener("pointerdown", onDocumentPointerDown, true);
2837
+ document.addEventListener("pointerdown", onFormattingPointerDown);
2838
+ document.addEventListener("pointermove", onFormattingPointerMove);
2839
+ document.addEventListener("pointerup", onFormattingPointerEnd);
2840
+ document.addEventListener("pointercancel", onFormattingPointerEnd);
2841
+ document.addEventListener("keydown", onFormattingKeyDown);
2773
2842
  document.addEventListener("pointerdown", onResizePointerDown, true);
2774
2843
  document.addEventListener("pointerup", onResizePointerUp, true);
2775
2844
  root.value?.addEventListener("error", onImageError, true);
@@ -2779,7 +2848,14 @@ var LoomaEditor = defineComponent({
2779
2848
  window.removeEventListener("scroll", onViewportChange, true);
2780
2849
  window.visualViewport?.removeEventListener("resize", onViewportChange);
2781
2850
  window.visualViewport?.removeEventListener("scroll", onViewportChange);
2851
+ cancelHeldPress();
2852
+ formattingPopup = null;
2782
2853
  document.removeEventListener("pointerdown", onDocumentPointerDown, true);
2854
+ document.removeEventListener("pointerdown", onFormattingPointerDown);
2855
+ document.removeEventListener("pointermove", onFormattingPointerMove);
2856
+ document.removeEventListener("pointerup", onFormattingPointerEnd);
2857
+ document.removeEventListener("pointercancel", onFormattingPointerEnd);
2858
+ document.removeEventListener("keydown", onFormattingKeyDown);
2783
2859
  document.removeEventListener("pointerdown", onResizePointerDown, true);
2784
2860
  document.removeEventListener("pointerup", onResizePointerUp, true);
2785
2861
  root.value?.removeEventListener("error", onImageError, true);
@@ -3131,8 +3207,15 @@ var LoomaEditor = defineComponent({
3131
3207
  instance && props.editable && !mobile.value && (props.toolbarMode === "bubble" || props.toolbarMode === "popover" || props.toolbarMode === "contextual") ? h(BubbleMenu, {
3132
3208
  editor: instance,
3133
3209
  pluginKey: "looma-text-formatting-menu",
3134
- shouldShow: ({ editor: menuEditor, from, to }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && (props.toolbarMode === "contextual" ? !slash.active && !mention.active && (menuEditor.isFocused || linkContextEditing.value) : shouldShowTextFormattingToolbar(menuEditor, from, to)),
3210
+ updateDelay: 0,
3211
+ shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (menuEditor.isFocused || linkContextEditing.value) && formattingSelectionActive(menuEditor),
3135
3212
  tippyOptions: {
3213
+ onCreate: (popup) => {
3214
+ formattingPopup = popup;
3215
+ },
3216
+ onDestroy: () => {
3217
+ formattingPopup = null;
3218
+ },
3136
3219
  appendTo: () => root.value?.closest("dialog[open], [popover]") ?? document.body,
3137
3220
  onShow: () => announceOverlayOpen(root.value?.ownerDocument ?? document, root.value),
3138
3221
  duration: 100,