@threadlabs/looma 0.23.25 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -219,8 +219,16 @@ export function createAnchoredSurface(surface, options = {}) {
219
219
  fallbackPosition(surface, { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }, "bottom-start", gap(), viewportGap);
220
220
  return;
221
221
  }
222
- if (!anchor) return;
223
- lastAnchorRect = anchor.getBoundingClientRect();
222
+ const rect = anchor?.getBoundingClientRect();
223
+ // A disappearing trigger has no usable position. Keep the last valid position, or wait before first opening.
224
+ if (!anchor || anchor.isConnected === false || !rect || rect.width === 0 && rect.height === 0
225
+ || anchor instanceof owner.Element && owner.getComputedStyle(anchor).visibility !== "visible") {
226
+ if (lastAnchorRect) fallbackPosition(surface, lastAnchorRect, placement, gap(), viewportGap);
227
+ else hide(surface);
228
+ return;
229
+ }
230
+ lastAnchorRect = rect;
231
+ show(surface);
224
232
  fallbackPosition(surface, lastAnchorRect, placement, gap(), viewportGap);
225
233
  };
226
234
  const schedule = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.23.25",
3
+ "version": "0.24.1",
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
- import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
- import { Extension, Node as Node$1, getText, getTextSerializersFromSchema, mergeAttributes, nodeInputRule } from "@tiptap/core";
8
+ import { AllSelection, NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
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,22 @@ 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 formattingPickerOpen = () => linkOpen.value || tablePickerOpen.value;
1805
+ const cancelHeldPress = () => {
1806
+ clearTimeout(heldPressTimer);
1807
+ heldPressTimer = void 0;
1808
+ heldPress = null;
1809
+ };
1810
+ const formattingSelectionActive = (instance) => {
1811
+ const selection = instance.state.selection;
1812
+ return (selection instanceof TextSelection || selection instanceof AllSelection && Boolean(instance.state.doc.textContent)) && !dismissedFormattingSelection?.eq(selection) && (!selection.empty || Boolean(heldFormattingSelection?.eq(selection)));
1813
+ };
1798
1814
  const editorStateVersion = ref(0);
1799
1815
  const mobileToolbarMode = ref("formatting");
1800
1816
  let dragLeaveTimer = null;
@@ -1995,6 +2011,12 @@ var LoomaEditor = defineComponent({
1995
2011
  onFocus: ({ editor: instance }) => rememberSelection(instance),
1996
2012
  onSelectionUpdate: ({ editor: instance }) => {
1997
2013
  rememberSelection(instance);
2014
+ if (floatingFormattingMode()) {
2015
+ const selection = instance.state.selection;
2016
+ if (!dismissedFormattingSelection?.eq(selection)) dismissedFormattingSelection = null;
2017
+ if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2018
+ if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2019
+ }
1998
2020
  if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
1999
2021
  },
2000
2022
  onTransaction: () => {
@@ -2442,13 +2464,21 @@ var LoomaEditor = defineComponent({
2442
2464
  editor.value?.commands.dismissLoomaSlashMenu();
2443
2465
  });
2444
2466
  watch(() => props.editable, (editable) => {
2467
+ cancelHeldPress();
2468
+ heldFormattingSelection = null;
2445
2469
  editor.value?.setEditable(editable);
2470
+ if (!editable) formattingPopup?.hide();
2446
2471
  if (editor.value) imageDelivery.reset(editor.value);
2447
2472
  root.value?.querySelectorAll("[data-looma-chip]").forEach((chip) => {
2448
2473
  chip.setAttribute("aria-disabled", String(!editable));
2449
2474
  });
2450
2475
  if (!editable) chipOpen.value = false;
2451
2476
  });
2477
+ watch([linkOpen, tablePickerOpen], ([link, table]) => {
2478
+ if (link || table || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.popper.contains(document.activeElement)) return;
2479
+ heldFormattingSelection = null;
2480
+ formattingPopup?.hide();
2481
+ });
2452
2482
  watch(() => props.label, (label) => {
2453
2483
  editor.value?.setOptions({ editorProps: { attributes: {
2454
2484
  role: "textbox",
@@ -2638,10 +2668,12 @@ var LoomaEditor = defineComponent({
2638
2668
  updateMobileViewport();
2639
2669
  };
2640
2670
  const onEditorBlur = () => {
2671
+ cancelHeldPress();
2641
2672
  queueMicrotask(updateTableUi);
2642
2673
  updateCodeUi();
2643
2674
  setTimeout(() => {
2644
2675
  if (!tableInteractionActive && !editor.value?.isFocused) editorFocused.value = false;
2676
+ if (!editor.value?.isFocused && !formattingPickerOpen() && !formattingPopup?.popper.contains(document.activeElement)) heldFormattingSelection = null;
2645
2677
  }, 0);
2646
2678
  };
2647
2679
  const bindEditorUi = (instance) => {
@@ -2667,9 +2699,13 @@ var LoomaEditor = defineComponent({
2667
2699
  }, { immediate: true });
2668
2700
  const onViewportChange = (event) => {
2669
2701
  if (event?.type === "scroll" && event.target instanceof Node && (tableToolbarShell.value?.contains(event.target) || tableMenuShell.value?.contains(event.target))) return;
2702
+ cancelHeldPress();
2703
+ const wasMobile = mobile.value;
2670
2704
  updateMobileViewport();
2705
+ if (wasMobile !== mobile.value) heldFormattingSelection = null;
2671
2706
  updateTableUi();
2672
2707
  updateCodeUi();
2708
+ if (floatingFormattingMode() && editor.value && !formattingSelectionActive(editor.value)) formattingPopup?.hide();
2673
2709
  tableUi.menuOpen = false;
2674
2710
  };
2675
2711
  const onDocumentPointerDown = (event) => {
@@ -2682,6 +2718,40 @@ var LoomaEditor = defineComponent({
2682
2718
  }, 0);
2683
2719
  if ((tableUi.toolbarOpen || tableUi.overlayOpen || tableUi.menuOpen) && !inElement(root.value) && !inTableUi) closeTableUi();
2684
2720
  };
2721
+ const onFormattingPointerDown = (event) => {
2722
+ cancelHeldPress();
2723
+ const instance = editor.value;
2724
+ 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;
2725
+ heldPress = {
2726
+ id: event.pointerId,
2727
+ x: event.clientX,
2728
+ y: event.clientY
2729
+ };
2730
+ heldPressTimer = setTimeout(() => {
2731
+ heldPressTimer = void 0;
2732
+ const selection = instance.state.selection;
2733
+ if (!heldPress || !floatingFormattingMode() || tableResizeActive || !props.editable || !instance.isFocused || slash.active || mention.active || !(selection instanceof TextSelection)) return;
2734
+ dismissedFormattingSelection = null;
2735
+ heldFormattingSelection = selection;
2736
+ formattingPopup?.setProps({ getReferenceClientRect: () => posToDOMRect(instance.view, selection.from, selection.to) });
2737
+ formattingPopup?.show();
2738
+ }, 500);
2739
+ };
2740
+ const onFormattingPointerMove = (event) => {
2741
+ if (heldPress?.id === event.pointerId && Math.hypot(event.clientX - heldPress.x, event.clientY - heldPress.y) > 8) cancelHeldPress();
2742
+ };
2743
+ const onFormattingPointerEnd = (event) => {
2744
+ if (heldPress?.id === event.pointerId) cancelHeldPress();
2745
+ };
2746
+ const onFormattingKeyDown = (event) => {
2747
+ cancelHeldPress();
2748
+ if (event.key !== "Escape" || !floatingFormattingMode() || !(event.target instanceof Node) || !(root.value?.contains(event.target) || formattingPopup?.popper.contains(event.target))) return;
2749
+ const selection = editor.value?.state.selection;
2750
+ dismissedFormattingSelection = selection instanceof TextSelection || selection instanceof AllSelection ? selection : null;
2751
+ heldFormattingSelection = null;
2752
+ formattingPopup?.hide();
2753
+ hideTool();
2754
+ };
2685
2755
  const onResizePointerDown = (event) => {
2686
2756
  tableResizeActive = event.target instanceof HTMLElement && event.target.classList.contains("column-resize-handle");
2687
2757
  };
@@ -2770,6 +2840,11 @@ var LoomaEditor = defineComponent({
2770
2840
  window.visualViewport?.addEventListener("resize", onViewportChange);
2771
2841
  window.visualViewport?.addEventListener("scroll", onViewportChange);
2772
2842
  document.addEventListener("pointerdown", onDocumentPointerDown, true);
2843
+ document.addEventListener("pointerdown", onFormattingPointerDown);
2844
+ document.addEventListener("pointermove", onFormattingPointerMove);
2845
+ document.addEventListener("pointerup", onFormattingPointerEnd);
2846
+ document.addEventListener("pointercancel", onFormattingPointerEnd);
2847
+ document.addEventListener("keydown", onFormattingKeyDown);
2773
2848
  document.addEventListener("pointerdown", onResizePointerDown, true);
2774
2849
  document.addEventListener("pointerup", onResizePointerUp, true);
2775
2850
  root.value?.addEventListener("error", onImageError, true);
@@ -2779,7 +2854,14 @@ var LoomaEditor = defineComponent({
2779
2854
  window.removeEventListener("scroll", onViewportChange, true);
2780
2855
  window.visualViewport?.removeEventListener("resize", onViewportChange);
2781
2856
  window.visualViewport?.removeEventListener("scroll", onViewportChange);
2857
+ cancelHeldPress();
2858
+ formattingPopup = null;
2782
2859
  document.removeEventListener("pointerdown", onDocumentPointerDown, true);
2860
+ document.removeEventListener("pointerdown", onFormattingPointerDown);
2861
+ document.removeEventListener("pointermove", onFormattingPointerMove);
2862
+ document.removeEventListener("pointerup", onFormattingPointerEnd);
2863
+ document.removeEventListener("pointercancel", onFormattingPointerEnd);
2864
+ document.removeEventListener("keydown", onFormattingKeyDown);
2783
2865
  document.removeEventListener("pointerdown", onResizePointerDown, true);
2784
2866
  document.removeEventListener("pointerup", onResizePointerUp, true);
2785
2867
  root.value?.removeEventListener("error", onImageError, true);
@@ -3131,10 +3213,18 @@ var LoomaEditor = defineComponent({
3131
3213
  instance && props.editable && !mobile.value && (props.toolbarMode === "bubble" || props.toolbarMode === "popover" || props.toolbarMode === "contextual") ? h(BubbleMenu, {
3132
3214
  editor: instance,
3133
3215
  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)),
3216
+ updateDelay: 0,
3217
+ shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (menuEditor.isFocused || linkContextEditing.value || formattingPickerOpen()) && formattingSelectionActive(menuEditor),
3135
3218
  tippyOptions: {
3219
+ onCreate: (popup) => {
3220
+ formattingPopup = popup;
3221
+ },
3222
+ onDestroy: () => {
3223
+ formattingPopup = null;
3224
+ },
3136
3225
  appendTo: () => root.value?.closest("dialog[open], [popover]") ?? document.body,
3137
3226
  onShow: () => announceOverlayOpen(root.value?.ownerDocument ?? document, root.value),
3227
+ onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
3138
3228
  duration: 100,
3139
3229
  maxWidth: "none",
3140
3230
  placement: "top",