@threadlabs/looma 0.29.2 → 0.29.4

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,47 @@
1
+ import type { EditorState } from "@tiptap/pm/state";
2
+ /** Host-owned non-mutating commands for the selected passage. */
3
+ export interface LoomaEditorSelectionAction {
4
+ id: string;
5
+ label: string;
6
+ }
7
+ /** Host action available at a collapsed caret inside an ephemeral editor range. */
8
+ export interface LoomaEditorRangeAction extends LoomaEditorSelectionAction {
9
+ from: number;
10
+ to: number;
11
+ }
12
+ /** Matching ranges are grouped into one control for each action ID. */
13
+ export interface LoomaEditorRangeActionDetail {
14
+ id: string;
15
+ ranges: {
16
+ from: number;
17
+ to: number;
18
+ }[];
19
+ }
20
+ /** Exact editor range captured before focus leaves for a host action. */
21
+ export interface LoomaEditorSelectionActionDetail {
22
+ id: string;
23
+ from: number;
24
+ to: number;
25
+ text: string;
26
+ before: string;
27
+ after: string;
28
+ }
29
+ /**
30
+ * The range actions whose range holds the collapsed text caret.
31
+ * @contract Ranges that are not whole numbers, are empty, or fall outside the document are ignored.
32
+ */
33
+ export declare function rangeActionsAtCaret(state: EditorState, actions: readonly LoomaEditorRangeAction[]): LoomaEditorRangeAction[];
34
+ /** One control per action ID: its label and each distinct range it covers at the caret, in order. */
35
+ export declare function groupRangeActions(actions: readonly LoomaEditorRangeAction[]): Array<{
36
+ id: string;
37
+ label: string;
38
+ detail: LoomaEditorRangeActionDetail;
39
+ }>;
40
+ /** Whether the selection is a passage that selection actions apply to: nonempty text, or everything. */
41
+ export declare function selectsPassage(state: EditorState): boolean;
42
+ /**
43
+ * A selection action's detail for the current passage: its range, its text, and up to 48 characters
44
+ * of context on each side, captured before focus leaves the editor.
45
+ * @failure Returns null when the selection is empty.
46
+ */
47
+ export declare function selectionActionDetail(state: EditorState, id: string): LoomaEditorSelectionActionDetail | null;
@@ -0,0 +1,29 @@
1
+ import type { ViewportRect } from "./viewport.js";
2
+ /** The widest viewport, in CSS pixels, that gets the editing dock; the slash and mention menus use the same width. */
3
+ export declare const LOOMA_MOBILE_DOCK_MAX_WIDTH = 767;
4
+ /** Whether a viewport this wide gets the editing dock rather than floating tools. */
5
+ export declare const usesMobileDock: (viewportWidth: number) => boolean;
6
+ /**
7
+ * Where the dock sits: across the visual viewport, against its bottom edge, so an on-screen keyboard
8
+ * pushes it up. It never rises above the viewport's top.
9
+ */
10
+ export declare function mobileDockPlacement(viewport: ViewportRect, dockHeight: number): {
11
+ left: number;
12
+ top: number;
13
+ width: number;
14
+ };
15
+ export type LoomaMobileDockMode = "formatting" | "table";
16
+ /**
17
+ * Which controls the dock shows. A table holding the selection shows its controls, until the author
18
+ * goes back to formatting; that choice lasts until no table is active.
19
+ *
20
+ * @ownership The host reports table activity and renders the mode; this owns the choice.
21
+ */
22
+ export interface LoomaMobileDock {
23
+ readonly mode: LoomaMobileDockMode;
24
+ tableActive(): void;
25
+ showFormatting(): void;
26
+ tableInactive(): void;
27
+ }
28
+ /** Calls `onChange` with the new mode whenever it changes. */
29
+ export declare function createMobileDock(onChange: (mode: LoomaMobileDockMode) => void): LoomaMobileDock;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.29.2",
3
+ "version": "0.29.4",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -6,6 +6,7 @@ import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaCodeLanguages
6
6
  import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageSourceResolver, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
7
7
  import { type LoomaImageUploadDetail } from "../../editor/image-uploads.js";
8
8
  import { type LoomaResponder } from "../../editor/requests.js";
9
+ import { type LoomaEditorRangeAction, type LoomaEditorRangeActionDetail, type LoomaEditorSelectionAction, type LoomaEditorSelectionActionDetail } from "../../editor/host-actions.js";
9
10
  import { type LoomaLinkTarget } from "../../editor/link-editing.js";
10
11
  export type { LoomaImageUploadDetail } from "../../editor/image-uploads.js";
11
12
  export type { LoomaRequestInit, LoomaResponder } from "../../editor/requests.js";
@@ -17,33 +18,7 @@ export type { LoomaRequestInit, LoomaResponder } from "../../editor/requests.js"
17
18
  * set. Narrow viewports retain a dock; document commands/content are unchanged.
18
19
  */
19
20
  export type LoomaEditorToolbarMode = "bubble" | "sticky" | "popover" | "contextual";
20
- /** Host-owned non-mutating commands for the selected passage. */
21
- export interface LoomaEditorSelectionAction {
22
- id: string;
23
- label: string;
24
- }
25
- /** Host action available at a collapsed caret inside an ephemeral editor range. */
26
- export interface LoomaEditorRangeAction extends LoomaEditorSelectionAction {
27
- from: number;
28
- to: number;
29
- }
30
- /** Matching ranges are grouped into one control for each action ID. */
31
- export interface LoomaEditorRangeActionDetail {
32
- id: string;
33
- ranges: {
34
- from: number;
35
- to: number;
36
- }[];
37
- }
38
- /** Exact editor range captured before focus leaves for a host action. */
39
- export interface LoomaEditorSelectionActionDetail {
40
- id: string;
41
- from: number;
42
- to: number;
43
- text: string;
44
- before: string;
45
- after: string;
46
- }
21
+ export type { LoomaEditorRangeAction, LoomaEditorRangeActionDetail, LoomaEditorSelectionAction, LoomaEditorSelectionActionDetail, } from "../../editor/host-actions.js";
47
22
  export type { LoomaLinkTarget } from "../../editor/link-editing.js";
48
23
  /** The `slash-commands` request's detail: this editor's default commands. */
49
24
  export interface LoomaSlashCommandsDetail {
@@ -1751,6 +1751,105 @@ function createChipEditing(options) {
1751
1751
  };
1752
1752
  }
1753
1753
  //#endregion
1754
+ //#region src/editor/host-actions.ts
1755
+ /** Characters of context on each side of a selected passage. */
1756
+ var CONTEXT = 48;
1757
+ /**
1758
+ * The range actions whose range holds the collapsed text caret.
1759
+ * @contract Ranges that are not whole numbers, are empty, or fall outside the document are ignored.
1760
+ */
1761
+ function rangeActionsAtCaret(state, actions) {
1762
+ const selection = state.selection;
1763
+ if (!(selection instanceof TextSelection) || !selection.empty) return [];
1764
+ const size = state.doc.content.size;
1765
+ return actions.filter(({ from, to }) => Number.isSafeInteger(from) && Number.isSafeInteger(to) && from >= 0 && to > from && to <= size && from <= selection.from && selection.from <= to);
1766
+ }
1767
+ /** One control per action ID: its label and each distinct range it covers at the caret, in order. */
1768
+ function groupRangeActions(actions) {
1769
+ const groups = /* @__PURE__ */ new Map();
1770
+ for (const { id, label, from, to } of actions) {
1771
+ const group = groups.get(id) ?? {
1772
+ label,
1773
+ ranges: /* @__PURE__ */ new Map()
1774
+ };
1775
+ group.ranges.set(`${from}:${to}`, {
1776
+ from,
1777
+ to
1778
+ });
1779
+ groups.set(id, group);
1780
+ }
1781
+ return [...groups].map(([id, { label, ranges }]) => ({
1782
+ id,
1783
+ label,
1784
+ detail: {
1785
+ id,
1786
+ ranges: [...ranges.values()]
1787
+ }
1788
+ }));
1789
+ }
1790
+ /** Whether the selection is a passage that selection actions apply to: nonempty text, or everything. */
1791
+ function selectsPassage(state) {
1792
+ const selection = state.selection;
1793
+ return !selection.empty && (selection instanceof TextSelection || selection instanceof AllSelection);
1794
+ }
1795
+ /**
1796
+ * A selection action's detail for the current passage: its range, its text, and up to 48 characters
1797
+ * of context on each side, captured before focus leaves the editor.
1798
+ * @failure Returns null when the selection is empty.
1799
+ */
1800
+ function selectionActionDetail(state, id) {
1801
+ const { from, to } = state.selection;
1802
+ if (from === to) return null;
1803
+ const doc = state.doc;
1804
+ return {
1805
+ id,
1806
+ from,
1807
+ to,
1808
+ text: doc.textBetween(from, to, "\n"),
1809
+ before: doc.textBetween(Math.max(0, from - CONTEXT), from, "\n"),
1810
+ after: doc.textBetween(to, Math.min(doc.content.size, to + CONTEXT), "\n")
1811
+ };
1812
+ }
1813
+ /** Whether a viewport this wide gets the editing dock rather than floating tools. */
1814
+ var usesMobileDock = (viewportWidth) => viewportWidth <= 767;
1815
+ /**
1816
+ * Where the dock sits: across the visual viewport, against its bottom edge, so an on-screen keyboard
1817
+ * pushes it up. It never rises above the viewport's top.
1818
+ */
1819
+ function mobileDockPlacement(viewport, dockHeight) {
1820
+ return {
1821
+ left: viewport.left,
1822
+ top: Math.max(viewport.top, viewport.bottom - dockHeight),
1823
+ width: viewport.width
1824
+ };
1825
+ }
1826
+ /** Calls `onChange` with the new mode whenever it changes. */
1827
+ function createMobileDock(onChange) {
1828
+ let mode = "formatting";
1829
+ let formattingChosen = false;
1830
+ const set = (next) => {
1831
+ if (next === mode) return;
1832
+ mode = next;
1833
+ onChange(mode);
1834
+ };
1835
+ return {
1836
+ get mode() {
1837
+ return mode;
1838
+ },
1839
+ tableActive() {
1840
+ if (!formattingChosen) set("table");
1841
+ },
1842
+ showFormatting() {
1843
+ formattingChosen = true;
1844
+ set("formatting");
1845
+ },
1846
+ tableInactive() {
1847
+ formattingChosen = false;
1848
+ set("formatting");
1849
+ }
1850
+ };
1851
+ }
1852
+ //#endregion
1754
1853
  //#region src/editor/extensions/relative-links.ts
1755
1854
  /** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
1756
1855
  function siteRelativeHref(href, baseUrl) {
@@ -3448,7 +3547,7 @@ var LoomaEditor = defineComponent({
3448
3547
  });
3449
3548
  const tablePickerOpen = ref(false);
3450
3549
  const mobileToolbarStyle = ref({});
3451
- const mobile = ref(typeof window !== "undefined" && window.innerWidth <= 767);
3550
+ const mobile = ref(typeof window !== "undefined" && usesMobileDock(window.innerWidth));
3452
3551
  const editorFocused = ref(false);
3453
3552
  let formatting;
3454
3553
  let formattingPopup = null;
@@ -3456,18 +3555,16 @@ var LoomaEditor = defineComponent({
3456
3555
  let imagePopup = null;
3457
3556
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
3458
3557
  const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
3459
- const activeRangeActions = (instance) => {
3460
- const selection = instance.state.selection;
3461
- if (!(selection instanceof TextSelection) || !selection.empty) return [];
3462
- 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);
3463
- };
3558
+ const activeRangeActions = (instance) => rangeActionsAtCaret(instance.state, props.rangeActions);
3464
3559
  const formattingActive = () => formatting?.active() ?? false;
3465
3560
  const editorStateVersion = ref(0);
3466
3561
  const mobileToolbarMode = ref("formatting");
3562
+ const mobileDock = createMobileDock((mode) => {
3563
+ mobileToolbarMode.value = mode;
3564
+ });
3467
3565
  let dragLeaveTimer = null;
3468
3566
  let tableResizeActive = false;
3469
3567
  let tableInteractionActive = false;
3470
- let mobileTableControlsDismissed = false;
3471
3568
  let hoveredTableCell = null;
3472
3569
  let overlayTableElement = null;
3473
3570
  let tablePointerFrame = null;
@@ -3937,22 +4034,20 @@ var LoomaEditor = defineComponent({
3937
4034
  tableUi.geometry = null;
3938
4035
  tableUi.capabilities = { ...EMPTY_CAPABILITIES };
3939
4036
  overlayTableElement = null;
3940
- mobileTableControlsDismissed = false;
3941
- mobileToolbarMode.value = "formatting";
4037
+ mobileDock.tableInactive();
3942
4038
  };
3943
4039
  const updateMobileViewport = () => {
3944
- mobile.value = window.innerWidth <= 767;
4040
+ mobile.value = usesMobileDock(window.innerWidth);
3945
4041
  if (!mobile.value) {
3946
4042
  mobileToolbarStyle.value = {};
3947
4043
  return;
3948
4044
  }
3949
4045
  nextTick(() => {
3950
- const viewport = getVisualViewportRect(window);
3951
- const toolbarHeight = mobileToolbarShell.value?.getBoundingClientRect().height || 56;
4046
+ const placement = mobileDockPlacement(getVisualViewportRect(window), mobileToolbarShell.value?.getBoundingClientRect().height || 56);
3952
4047
  mobileToolbarStyle.value = {
3953
- left: `${viewport.left}px`,
3954
- top: `${Math.max(viewport.top, viewport.bottom - toolbarHeight)}px`,
3955
- width: `${viewport.width}px`
4048
+ left: `${placement.left}px`,
4049
+ top: `${placement.top}px`,
4050
+ width: `${placement.width}px`
3956
4051
  };
3957
4052
  });
3958
4053
  };
@@ -4042,7 +4137,7 @@ var LoomaEditor = defineComponent({
4042
4137
  }
4043
4138
  const selectedTableIsActive = state.active && selectedTable === table;
4044
4139
  overlayTableElement = table;
4045
- if (selectedTableIsActive && !mobileTableControlsDismissed) mobileToolbarMode.value = "table";
4140
+ if (selectedTableIsActive) mobileDock.tableActive();
4046
4141
  const rect = table.getBoundingClientRect();
4047
4142
  const maxToolbarWidth = Math.min(420, window.innerWidth - 24);
4048
4143
  const toolbarLeft = Math.min(Math.max(12, rect.left + (rect.width - maxToolbarWidth) / 2), Math.max(12, window.innerWidth - maxToolbarWidth - 12));
@@ -4514,40 +4609,22 @@ var LoomaEditor = defineComponent({
4514
4609
  onClick: run
4515
4610
  }, () => loomaIcon(icon));
4516
4611
  };
4517
- const renderRangeActions = (instance) => {
4518
- const groups = /* @__PURE__ */ new Map();
4519
- for (const action of activeRangeActions(instance)) {
4520
- const group = groups.get(action.id) ?? {
4521
- label: action.label,
4522
- ranges: /* @__PURE__ */ new Map()
4523
- };
4524
- group.ranges.set(`${action.from}:${action.to}`, {
4525
- from: action.from,
4526
- to: action.to
4527
- });
4528
- groups.set(action.id, group);
4612
+ const renderRangeActions = (instance) => groupRangeActions(activeRangeActions(instance)).map(({ id, label, detail }) => h(Button, {
4613
+ key: id,
4614
+ "aria-label": label,
4615
+ type: "button",
4616
+ variant: "ghost",
4617
+ tone: "neutral",
4618
+ size: "sm",
4619
+ onPointerdown: (event) => event.preventDefault(),
4620
+ onClick: () => {
4621
+ emit("rangeAction", detail);
4622
+ formattingPopup?.hide();
4529
4623
  }
4530
- return [...groups].map(([id, group]) => h(Button, {
4531
- key: id,
4532
- "aria-label": group.label,
4533
- type: "button",
4534
- variant: "ghost",
4535
- tone: "neutral",
4536
- size: "sm",
4537
- onPointerdown: (event) => event.preventDefault(),
4538
- onClick: () => {
4539
- emit("rangeAction", {
4540
- id,
4541
- ranges: [...group.ranges.values()]
4542
- });
4543
- formattingPopup?.hide();
4544
- }
4545
- }, () => group.label));
4546
- };
4624
+ }, () => label));
4547
4625
  const renderSelectionActions = (instance) => {
4548
- const selection = instance.state.selection;
4549
- if (selection.empty) return renderRangeActions(instance);
4550
- if (!(selection instanceof TextSelection || selection instanceof AllSelection)) return [];
4626
+ if (instance.state.selection.empty) return renderRangeActions(instance);
4627
+ if (!selectsPassage(instance.state)) return [];
4551
4628
  return props.selectionActions.map((action) => h(Button, {
4552
4629
  key: action.id,
4553
4630
  "aria-label": action.label,
@@ -4557,17 +4634,9 @@ var LoomaEditor = defineComponent({
4557
4634
  size: "sm",
4558
4635
  onPointerdown: (event) => event.preventDefault(),
4559
4636
  onClick: () => {
4560
- const { from, to } = instance.state.selection;
4561
- if (from === to) return;
4562
- const doc = instance.state.doc;
4563
- emit("selectionAction", {
4564
- id: action.id,
4565
- from,
4566
- to,
4567
- text: doc.textBetween(from, to, "\n"),
4568
- before: doc.textBetween(Math.max(0, from - 48), from, "\n"),
4569
- after: doc.textBetween(to, Math.min(doc.content.size, to + 48), "\n")
4570
- });
4637
+ const detail = selectionActionDetail(instance.state, action.id);
4638
+ if (!detail) return;
4639
+ emit("selectionAction", detail);
4571
4640
  formattingPopup?.hide();
4572
4641
  }
4573
4642
  }, () => action.label));
@@ -4823,8 +4892,7 @@ var LoomaEditor = defineComponent({
4823
4892
  type: "button",
4824
4893
  "aria-label": "Formatting tools",
4825
4894
  onClick: () => {
4826
- mobileTableControlsDismissed = true;
4827
- mobileToolbarMode.value = "formatting";
4895
+ mobileDock.showFormatting();
4828
4896
  editor.value?.commands.focus();
4829
4897
  nextTick(updateMobileViewport);
4830
4898
  }