@threadlabs/looma 0.28.12 → 0.28.13

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,49 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { TextSelection, type Selection } from "@tiptap/pm/state";
3
+ export interface LoomaFormattingSelectionOptions {
4
+ editor: Editor;
5
+ /** Whether a press held on the text may open the tools: floating tools, an editable document, no resize in progress. */
6
+ canHold: () => boolean;
7
+ /** Whether a slash or mention menu is open, which a completed hold leaves alone. */
8
+ menuOpen: () => boolean;
9
+ /** Whether something other than a selection, such as a range action at the caret, shows the tools. */
10
+ shownAtCaret: () => boolean;
11
+ /** Opens the tools at the caret a press was held on. */
12
+ onHold: (selection: TextSelection) => void;
13
+ }
14
+ /**
15
+ * Which selection the editor's floating formatting tools belong to: a text selection, a caret the
16
+ * reader pressed and held, never one the reader dismissed with Escape.
17
+ *
18
+ * @ownership The host owns the tools' surface and the document listeners that feed `press`; this
19
+ * owns the dismissed and held selections and the hold gesture's timer.
20
+ * @lifecycle `destroy()` cancels a pending hold.
21
+ */
22
+ export interface LoomaFormattingSelection {
23
+ /** Whether the current selection shows the tools. */
24
+ active(): boolean;
25
+ /** Whether `selection` is the one the reader dismissed. */
26
+ dismissed(selection: Selection): boolean;
27
+ /**
28
+ * Hides the tools for the current selection, or for `selection`, until the reader selects again.
29
+ * Without an argument only a text or whole-document selection is remembered.
30
+ */
31
+ dismiss(selection?: Selection | null): void;
32
+ /** Forgets a dismissal, as when the reader clicks a link. */
33
+ undismiss(): void;
34
+ /** Forgets a held caret. */
35
+ release(): void;
36
+ /** Forgets a dismissed or held selection that no longer matches the editor's. */
37
+ selectionChanged(): void;
38
+ /** Feeds the hold gesture: a still press for half a second on the text opens the tools at its caret. */
39
+ readonly press: {
40
+ down(event: PointerEvent): void;
41
+ move(event: PointerEvent): void;
42
+ end(event: PointerEvent): void;
43
+ cancel(): void;
44
+ /** A viewport change after the press began cancels it; the shared pass may report one from before. */
45
+ viewportChanged(event?: Event): void;
46
+ };
47
+ destroy(): void;
48
+ }
49
+ export declare function createFormattingSelection(options: LoomaFormattingSelectionOptions): LoomaFormattingSelection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.12",
3
+ "version": "0.28.13",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -1131,6 +1131,76 @@ function runBlockAction(editor, action) {
1131
1131
  return true;
1132
1132
  }
1133
1133
  //#endregion
1134
+ //#region src/editor/formatting-selection.ts
1135
+ var HOLD_MS = 500;
1136
+ var HOLD_SLOP_PX = 8;
1137
+ function createFormattingSelection(options) {
1138
+ const { editor } = options;
1139
+ let dismissedSelection = null;
1140
+ let heldSelection = null;
1141
+ let held = null;
1142
+ let timer;
1143
+ const cancel = () => {
1144
+ clearTimeout(timer);
1145
+ timer = void 0;
1146
+ held = null;
1147
+ };
1148
+ return {
1149
+ active() {
1150
+ const selection = editor.state.selection;
1151
+ return (selection instanceof TextSelection || selection instanceof AllSelection && Boolean(editor.state.doc.textContent)) && !dismissedSelection?.eq(selection) && (!selection.empty || Boolean(heldSelection?.eq(selection)) || options.shownAtCaret());
1152
+ },
1153
+ dismissed: (selection) => Boolean(dismissedSelection?.eq(selection)),
1154
+ dismiss(selection) {
1155
+ const current = editor.state.selection;
1156
+ dismissedSelection = selection !== void 0 ? selection : current instanceof TextSelection || current instanceof AllSelection ? current : null;
1157
+ heldSelection = null;
1158
+ },
1159
+ undismiss() {
1160
+ dismissedSelection = null;
1161
+ },
1162
+ release() {
1163
+ heldSelection = null;
1164
+ },
1165
+ selectionChanged() {
1166
+ const selection = editor.state.selection;
1167
+ if (!dismissedSelection?.eq(selection)) dismissedSelection = null;
1168
+ if (!heldSelection?.eq(selection)) heldSelection = null;
1169
+ },
1170
+ press: {
1171
+ down(event) {
1172
+ cancel();
1173
+ if (!options.canHold() || editor.isDestroyed || event.button !== 0 || !event.isPrimary || !(event.target instanceof Element) || !editor.view.dom.contains(event.target) || event.target.closest("button, input, select, textarea, [data-looma-image-node]")) return;
1174
+ held = {
1175
+ id: event.pointerId,
1176
+ x: event.clientX,
1177
+ y: event.clientY,
1178
+ time: event.timeStamp
1179
+ };
1180
+ timer = setTimeout(() => {
1181
+ timer = void 0;
1182
+ const selection = editor.state.selection;
1183
+ if (!held || !options.canHold() || !editor.isFocused || options.menuOpen() || !(selection instanceof TextSelection)) return;
1184
+ dismissedSelection = null;
1185
+ heldSelection = selection;
1186
+ options.onHold(selection);
1187
+ }, HOLD_MS);
1188
+ },
1189
+ move(event) {
1190
+ if (held?.id === event.pointerId && Math.hypot(event.clientX - held.x, event.clientY - held.y) > HOLD_SLOP_PX) cancel();
1191
+ },
1192
+ end(event) {
1193
+ if (held?.id === event.pointerId) cancel();
1194
+ },
1195
+ cancel,
1196
+ viewportChanged(event) {
1197
+ if (!event || !held || event.timeStamp >= held.time) cancel();
1198
+ }
1199
+ },
1200
+ destroy: cancel
1201
+ };
1202
+ }
1203
+ //#endregion
1134
1204
  //#region src/editor/table-overlay.ts
1135
1205
  function mapTableCells(table) {
1136
1206
  const rows = Array.from(table.rows);
@@ -2908,29 +2978,18 @@ var LoomaEditor = defineComponent({
2908
2978
  const mobileToolbarStyle = ref({});
2909
2979
  const mobile = ref(typeof window !== "undefined" && window.innerWidth <= 767);
2910
2980
  const editorFocused = ref(false);
2911
- let dismissedFormattingSelection = null;
2912
- let heldFormattingSelection = null;
2981
+ let formatting;
2913
2982
  let formattingPopup = null;
2914
2983
  let linkPopup = null;
2915
2984
  let imagePopup = null;
2916
- let heldPressTimer;
2917
- let heldPress = null;
2918
2985
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
2919
2986
  const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
2920
- const cancelHeldPress = () => {
2921
- clearTimeout(heldPressTimer);
2922
- heldPressTimer = void 0;
2923
- heldPress = null;
2924
- };
2925
2987
  const activeRangeActions = (instance) => {
2926
2988
  const selection = instance.state.selection;
2927
2989
  if (!(selection instanceof TextSelection) || !selection.empty) return [];
2928
2990
  return props.rangeActions.filter((action) => Number.isSafeInteger(action.from) && Number.isSafeInteger(action.to) && action.from >= 0 && action.to > action.from && action.to <= instance.state.doc.content.size && action.from <= selection.from && selection.from <= action.to);
2929
2991
  };
2930
- const formattingSelectionActive = (instance) => {
2931
- const selection = instance.state.selection;
2932
- return (selection instanceof TextSelection || selection instanceof AllSelection && Boolean(instance.state.doc.textContent)) && !dismissedFormattingSelection?.eq(selection) && (!selection.empty || Boolean(heldFormattingSelection?.eq(selection)) || activeRangeActions(instance).length > 0);
2933
- };
2992
+ const formattingActive = () => formatting?.active() ?? false;
2934
2993
  const editorStateVersion = ref(0);
2935
2994
  const mobileToolbarMode = ref("formatting");
2936
2995
  let dragLeaveTimer = null;
@@ -3100,7 +3159,7 @@ var LoomaEditor = defineComponent({
3100
3159
  window.open(href, "_blank", "noopener,noreferrer");
3101
3160
  return true;
3102
3161
  }
3103
- dismissedFormattingSelection = null;
3162
+ formatting?.undismiss();
3104
3163
  requestAnimationFrame(() => {
3105
3164
  if (view.isDestroyed) return;
3106
3165
  editorStateVersion.value += 1;
@@ -3123,10 +3182,9 @@ var LoomaEditor = defineComponent({
3123
3182
  onSelectionUpdate: ({ editor: instance }) => {
3124
3183
  rememberSelection(instance);
3125
3184
  if (floatingFormattingMode()) {
3126
- const selection = instance.state.selection;
3127
- if (!dismissedFormattingSelection?.eq(selection)) dismissedFormattingSelection = null;
3128
- if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
3129
- if (!formattingSelectionActive(instance)) formattingPopup?.hide();
3185
+ instance.state.selection;
3186
+ formatting?.selectionChanged();
3187
+ if (!formattingActive()) formattingPopup?.hide();
3130
3188
  }
3131
3189
  },
3132
3190
  onUpdate: ({ editor: instance }) => {
@@ -3149,6 +3207,17 @@ var LoomaEditor = defineComponent({
3149
3207
  linkState.value = engine.state;
3150
3208
  });
3151
3209
  linkEditing = engine;
3210
+ const instance = session.editor;
3211
+ formatting = createFormattingSelection({
3212
+ editor: instance,
3213
+ canHold: () => floatingFormattingMode() && !tableResizeActive && props.editable,
3214
+ menuOpen: () => slash.active || mention.active,
3215
+ shownAtCaret: () => activeRangeActions(instance).length > 0,
3216
+ onHold: (selection) => {
3217
+ formattingPopup?.setAnchor(() => posToDOMRect(instance.view, selection.from, selection.to));
3218
+ formattingPopup?.show();
3219
+ }
3220
+ });
3152
3221
  });
3153
3222
  const attachDocument = (element) => {
3154
3223
  if (element instanceof HTMLElement) session?.attach(element);
@@ -3170,7 +3239,7 @@ var LoomaEditor = defineComponent({
3170
3239
  const to = selection?.to ?? current.to;
3171
3240
  linkEditing?.open(from, to, context ? "context" : "popover");
3172
3241
  nextTick(() => {
3173
- if (context) (props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(instance) ? formattingPopup : linkPopup)?.setAnchor(() => posToDOMRect(instance.view, instance.state.selection.from, instance.state.selection.to));
3242
+ if (context) (props.toolbarMode === "contextual" && floatingFormattingMode() && formattingActive() ? formattingPopup : linkPopup)?.setAnchor(() => posToDOMRect(instance.view, instance.state.selection.from, instance.state.selection.to));
3174
3243
  (linkState.value.mode === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
3175
3244
  });
3176
3245
  };
@@ -3178,8 +3247,7 @@ var LoomaEditor = defineComponent({
3178
3247
  linkEditing?.close();
3179
3248
  const range = linkState.value.range;
3180
3249
  if (restoreFocus && range) editor.value?.chain().focus().setTextSelection(range.to).run();
3181
- dismissedFormattingSelection = editor.value?.state.selection ?? null;
3182
- heldFormattingSelection = null;
3250
+ formatting?.dismiss(editor.value?.state.selection ?? null);
3183
3251
  linkPopup?.hide();
3184
3252
  formattingPopup?.hide();
3185
3253
  };
@@ -3248,8 +3316,8 @@ var LoomaEditor = defineComponent({
3248
3316
  editor.value?.commands.dismissLoomaSlashMenu();
3249
3317
  });
3250
3318
  watch(() => props.editable, (editable) => {
3251
- cancelHeldPress();
3252
- heldFormattingSelection = null;
3319
+ formatting?.press.cancel();
3320
+ formatting?.release();
3253
3321
  editor.value?.setEditable(editable);
3254
3322
  if (!editable) formattingPopup?.hide();
3255
3323
  if (editor.value) imageDelivery.reset(editor.value);
@@ -3264,7 +3332,7 @@ var LoomaEditor = defineComponent({
3264
3332
  formatOpen
3265
3333
  ], ([linking, table, format]) => {
3266
3334
  if (linking || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
3267
- heldFormattingSelection = null;
3335
+ formatting?.release();
3268
3336
  formattingPopup?.hide();
3269
3337
  });
3270
3338
  watch(() => props.rangeActions, () => {
@@ -3440,12 +3508,12 @@ var LoomaEditor = defineComponent({
3440
3508
  updateMobileViewport();
3441
3509
  };
3442
3510
  const onEditorBlur = () => {
3443
- cancelHeldPress();
3511
+ formatting?.press.cancel();
3444
3512
  queueMicrotask(updateTableUi);
3445
3513
  updateCodeUi();
3446
3514
  setTimeout(() => {
3447
3515
  if (!tableInteractionActive && !editor.value?.isFocused && !mobileToolbarShell.value?.contains(document.activeElement)) editorFocused.value = false;
3448
- if (!editor.value?.isFocused && !formattingPickerOpen() && !formattingPopup?.element.contains(document.activeElement)) heldFormattingSelection = null;
3516
+ if (!editor.value?.isFocused && !formattingPickerOpen() && !formattingPopup?.element.contains(document.activeElement)) formatting?.release();
3449
3517
  }, 0);
3450
3518
  };
3451
3519
  const bindEditorUi = (instance) => {
@@ -3471,15 +3539,15 @@ var LoomaEditor = defineComponent({
3471
3539
  }, { immediate: true });
3472
3540
  const onViewportChange = (event) => {
3473
3541
  if (event?.type === "scroll" && event.target instanceof Node && (tableToolbarShell.value?.contains(event.target) || tableMenuShell.value?.contains(event.target))) return;
3474
- if (!event || !heldPress || event.timeStamp >= heldPress.time) cancelHeldPress();
3542
+ formatting?.press.viewportChanged(event);
3475
3543
  const wasMobile = mobile.value;
3476
3544
  if (slash.active && slash.getRect) slash.rect = slash.getRect();
3477
3545
  if (mention.active && mention.getRect) mention.rect = mention.getRect();
3478
3546
  updateMobileViewport();
3479
- if (wasMobile !== mobile.value) heldFormattingSelection = null;
3547
+ if (wasMobile !== mobile.value) formatting?.release();
3480
3548
  updateTableUi();
3481
3549
  updateCodeUi();
3482
- if (floatingFormattingMode() && editor.value && !formattingSelectionActive(editor.value)) formattingPopup?.hide();
3550
+ if (floatingFormattingMode() && editor.value && !formattingActive()) formattingPopup?.hide();
3483
3551
  tableUi.menuOpen = false;
3484
3552
  };
3485
3553
  let stopViewport;
@@ -3516,42 +3584,17 @@ var LoomaEditor = defineComponent({
3516
3584
  }, 0);
3517
3585
  if ((tableUi.toolbarOpen || tableUi.overlayOpen || tableUi.menuOpen) && !inElement(root.value) && !inTableUi) closeTableUi();
3518
3586
  };
3519
- const onFormattingPointerDown = (event) => {
3520
- cancelHeldPress();
3521
- const instance = editor.value;
3522
- 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;
3523
- heldPress = {
3524
- id: event.pointerId,
3525
- x: event.clientX,
3526
- y: event.clientY,
3527
- time: event.timeStamp
3528
- };
3529
- heldPressTimer = setTimeout(() => {
3530
- heldPressTimer = void 0;
3531
- const selection = instance.state.selection;
3532
- if (!heldPress || !floatingFormattingMode() || tableResizeActive || !props.editable || !instance.isFocused || slash.active || mention.active || !(selection instanceof TextSelection)) return;
3533
- dismissedFormattingSelection = null;
3534
- heldFormattingSelection = selection;
3535
- formattingPopup?.setAnchor(() => posToDOMRect(instance.view, selection.from, selection.to));
3536
- formattingPopup?.show();
3537
- }, 500);
3538
- };
3539
- const onFormattingPointerMove = (event) => {
3540
- if (heldPress?.id === event.pointerId && Math.hypot(event.clientX - heldPress.x, event.clientY - heldPress.y) > 8) cancelHeldPress();
3541
- };
3542
- const onFormattingPointerEnd = (event) => {
3543
- if (heldPress?.id === event.pointerId) cancelHeldPress();
3544
- };
3587
+ const onFormattingPointerDown = (event) => formatting?.press.down(event);
3588
+ const onFormattingPointerMove = (event) => formatting?.press.move(event);
3589
+ const onFormattingPointerEnd = (event) => formatting?.press.end(event);
3545
3590
  const onFormattingKeyDown = (event) => {
3546
- cancelHeldPress();
3591
+ formatting?.press.cancel();
3547
3592
  if (event.key !== "Escape" || !(event.target instanceof Node) || !(root.value?.contains(event.target) || formattingPopup?.element.contains(event.target) || linkPopup?.element.contains(event.target)) || !(formatOpen.value || formattingPopup?.isOpen() || linkPopup?.isOpen())) return false;
3548
3593
  dismissFormatting();
3549
3594
  return true;
3550
3595
  };
3551
3596
  const dismissFormatting = () => {
3552
- const selection = editor.value?.state.selection;
3553
- dismissedFormattingSelection = selection instanceof TextSelection || selection instanceof AllSelection ? selection : null;
3554
- heldFormattingSelection = null;
3597
+ formatting?.dismiss();
3555
3598
  formatOpen.value = false;
3556
3599
  formattingPopup?.hide();
3557
3600
  linkPopup?.hide();
@@ -3574,10 +3617,10 @@ var LoomaEditor = defineComponent({
3574
3617
  editor: instance,
3575
3618
  element,
3576
3619
  pluginKey: "looma-text-formatting-menu",
3577
- shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (!props.editable && (props.selectionActions.length > 0 || props.rangeActions.length > 0) || menuEditor.isFocused || formattingPopup?.element.contains(document.activeElement) || linkState.value.surface === "context" || formattingPickerOpen()) && formattingSelectionActive(menuEditor),
3620
+ shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (!props.editable && (props.selectionActions.length > 0 || props.rangeActions.length > 0) || menuEditor.isFocused || formattingPopup?.element.contains(document.activeElement) || linkState.value.surface === "context" || formattingPickerOpen()) && formattingActive(),
3578
3621
  boundary: () => props.toolbarMode === "contextual" ? root.value : null,
3579
3622
  related: editorRoot,
3580
- onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
3623
+ onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingActive() ? false : void 0,
3581
3624
  onDismiss: (reason) => reason === "escape" ? dismissFormatting() : formattingPopup?.hide()
3582
3625
  }));
3583
3626
  const linkSurfaceRef = surfaceRef((surface) => {
@@ -3586,7 +3629,7 @@ var LoomaEditor = defineComponent({
3586
3629
  editor: instance,
3587
3630
  element,
3588
3631
  pluginKey: "looma-link-context-menu",
3589
- shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") && linkState.value.surface !== "popover" && !dismissedFormattingSelection?.eq(menuEditor.state.selection) && Boolean(menuEditor.isFocused || linkState.value.surface === "context" || linkPopup?.element.contains(document.activeElement)) && !(props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(menuEditor)),
3632
+ shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") && linkState.value.surface !== "popover" && !formatting?.dismissed(menuEditor.state.selection) && Boolean(menuEditor.isFocused || linkState.value.surface === "context" || linkPopup?.element.contains(document.activeElement)) && !(props.toolbarMode === "contextual" && floatingFormattingMode() && formattingActive()),
3590
3633
  maxWidth: () => Math.min(480, window.innerWidth - 24),
3591
3634
  related: editorRoot,
3592
3635
  onDismiss: (reason) => reason === "escape" ? dismissFormatting() : linkPopup?.hide()
@@ -3753,7 +3796,7 @@ var LoomaEditor = defineComponent({
3753
3796
  window.removeEventListener("keyup", onLinkNavigationModifier);
3754
3797
  window.removeEventListener("pointermove", onLinkNavigationModifier);
3755
3798
  window.removeEventListener("blur", clearLinkNavigationModifier);
3756
- cancelHeldPress();
3799
+ formatting?.destroy();
3757
3800
  formattingPopup = null;
3758
3801
  document.removeEventListener("pointerdown", onDocumentPointerDown, true);
3759
3802
  document.removeEventListener("pointerdown", onFormattingPointerDown);
@@ -4183,12 +4226,12 @@ var LoomaEditor = defineComponent({
4183
4226
  ref: formattingSurfaceRef,
4184
4227
  class: "looma-editor__floating-shell looma-editor__formatting-shell",
4185
4228
  hidden: true
4186
- }, [props.toolbarMode === "contextual" ? h(Stack, { gap: "xs" }, () => [renderToolbar(instance, true), (linkState.value.surface === "context" || instance.state.selection.empty && instance.isActive("link")) && linkState.value.surface !== "popover" && formattingSelectionActive(instance) ? renderLinkContextCard(instance) : null]) : renderToolbar(instance, true, props.toolbarMode === "popover")])]) : null,
4229
+ }, [props.toolbarMode === "contextual" ? h(Stack, { gap: "xs" }, () => [renderToolbar(instance, true), (linkState.value.surface === "context" || instance.state.selection.empty && instance.isActive("link")) && linkState.value.surface !== "popover" && formattingActive() ? renderLinkContextCard(instance) : null]) : renderToolbar(instance, true, props.toolbarMode === "popover")])]) : null,
4187
4230
  instance && props.editable ? h("div", { key: "looma-link-host" }, [h("div", {
4188
4231
  ref: linkSurfaceRef,
4189
4232
  class: "looma-editor__floating-shell looma-editor__link-shell",
4190
4233
  hidden: true
4191
- }, [props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(instance) ? null : renderLinkContextCard(instance)])]) : null,
4234
+ }, [props.toolbarMode === "contextual" && floatingFormattingMode() && formattingActive() ? null : renderLinkContextCard(instance)])]) : null,
4192
4235
  instance && props.editable && !mobile.value && props.toolbarMode === "popover" && props.toolbarTriggerId ? h(Popover, {
4193
4236
  class: "looma-editor__formatting-popover",
4194
4237
  role: "region",