@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.
- package/editor/host-actions.d.ts +47 -0
- package/editor/mobile-dock.d.ts +29 -0
- package/package.json +1 -1
- package/vue/editor/LoomaEditor.d.ts +2 -27
- package/vue/editor/index.js +129 -61
|
@@ -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
|
@@ -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
|
-
|
|
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 {
|
package/vue/editor/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
3941
|
-
mobileToolbarMode.value = "formatting";
|
|
4037
|
+
mobileDock.tableInactive();
|
|
3942
4038
|
};
|
|
3943
4039
|
const updateMobileViewport = () => {
|
|
3944
|
-
mobile.value = window.innerWidth
|
|
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
|
|
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: `${
|
|
3954
|
-
top: `${
|
|
3955
|
-
width: `${
|
|
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
|
|
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
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4549
|
-
if (
|
|
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
|
|
4561
|
-
if (
|
|
4562
|
-
|
|
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
|
-
|
|
4827
|
-
mobileToolbarMode.value = "formatting";
|
|
4895
|
+
mobileDock.showFormatting();
|
|
4828
4896
|
editor.value?.commands.focus();
|
|
4829
4897
|
nextTick(updateMobileViewport);
|
|
4830
4898
|
}
|