@threadlabs/looma 0.29.1 → 0.29.3
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/chip-editing.d.ts +39 -0
- package/editor/host-actions.d.ts +47 -0
- package/package.json +1 -1
- package/vue/editor/LoomaEditor.d.ts +4 -29
- package/vue/editor/compact/index.d.ts +2 -2
- package/vue/editor/index.js +318 -124
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { type LoomaChipColor } from "./extensions/chip.js";
|
|
3
|
+
/** The chip editor's state: whether it is open, and the label and tone of the chip it edits. */
|
|
4
|
+
export interface LoomaChipEditingState {
|
|
5
|
+
readonly open: boolean;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly color: LoomaChipColor;
|
|
8
|
+
}
|
|
9
|
+
export declare const LOOMA_CHIP_EDITING_IDLE: LoomaChipEditingState;
|
|
10
|
+
/**
|
|
11
|
+
* The chip editor as a state machine: it opens on one chip and writes each label and tone change to
|
|
12
|
+
* that chip as the author makes it.
|
|
13
|
+
*
|
|
14
|
+
* @ownership The host owns presentation: the popover, its anchor, and focus. This owns which chip is
|
|
15
|
+
* edited and the node attributes it writes.
|
|
16
|
+
* @failure A change after the chip was deleted, or moved out of its node, closes the editor and writes
|
|
17
|
+
* nothing.
|
|
18
|
+
*/
|
|
19
|
+
export interface LoomaChipEditing {
|
|
20
|
+
readonly state: LoomaChipEditingState;
|
|
21
|
+
/** The edited chip's element, for anchoring the editor, or null while it is closed. */
|
|
22
|
+
readonly element: HTMLElement | null;
|
|
23
|
+
/** Calls `listener` after every state change; returns the unsubscribe function. */
|
|
24
|
+
subscribe(listener: () => void): () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Opens on the chip at `position`.
|
|
27
|
+
* @failure Returns false, and stays as it was, when the editor is read-only or no chip is there.
|
|
28
|
+
*/
|
|
29
|
+
open(position: number): boolean;
|
|
30
|
+
setLabel(label: string): void;
|
|
31
|
+
setColor(color: LoomaChipColor): void;
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
|
34
|
+
export interface LoomaChipEditingOptions {
|
|
35
|
+
editor: Editor;
|
|
36
|
+
/** Read on each opening, so a host can change it while the editor runs. */
|
|
37
|
+
isEditable: () => boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function createChipEditing(options: LoomaChipEditingOptions): LoomaChipEditing;
|
|
@@ -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;
|
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 {
|
|
@@ -402,10 +377,10 @@ export declare const LoomaEditor: import("vue").DefineComponent<import("vue").Ex
|
|
|
402
377
|
onImageActivate?: ((_detail: LoomaImageActivationDetail) => any) | undefined;
|
|
403
378
|
onImageRenditionError?: ((_detail: LoomaImageRenditionErrorDetail) => any) | undefined;
|
|
404
379
|
}>, {
|
|
405
|
-
extensions: LoomaEditorExtensions | undefined;
|
|
406
|
-
editable: boolean;
|
|
407
380
|
label: string;
|
|
408
381
|
placeholder: string;
|
|
382
|
+
extensions: LoomaEditorExtensions | undefined;
|
|
383
|
+
editable: boolean;
|
|
409
384
|
linkBaseUrl: string;
|
|
410
385
|
disableHighlight: boolean;
|
|
411
386
|
codeLanguages: Readonly<Record<string, import("highlight.js").LanguageFn>> | null;
|
|
@@ -62,10 +62,10 @@ export declare const LoomaCompactEditor: import("vue").DefineComponent<import("v
|
|
|
62
62
|
"onUpdate:modelValue"?: ((_value: JSONContent) => any) | undefined;
|
|
63
63
|
onReady?: ((_editor: Editor) => any) | undefined;
|
|
64
64
|
}>, {
|
|
65
|
+
id: string;
|
|
66
|
+
label: string;
|
|
65
67
|
extensions: AnyExtension[];
|
|
66
68
|
editable: boolean;
|
|
67
|
-
label: string;
|
|
68
|
-
id: string;
|
|
69
69
|
modelValue: JSONContent;
|
|
70
70
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
71
71
|
/** Renders basic compact JSON as escaped Vue nodes, including during SSR. */
|
package/vue/editor/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, onUpdated, reactive, ref, shallowRef, useId, watch } from "vue";
|
|
2
|
-
import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths,
|
|
2
|
+
import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeTableOfContentsConfig } from "@threadlabs/looma/editor";
|
|
3
3
|
import { Button, Callout, Card, Checkbox, Cluster, Disclosure, EditorBlockActions, EditorChipEditor, EditorCodeLanguage, EditorImageToolbar, EditorInsertTableGrid, EditorLinkCard, EditorLinkForm, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorSurface, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, FormField, Icon, IconButton, Image, Input, List, Menu, MenuItem, Popover, Separator, Stack, StatusMessage, Text, Tooltip } from "@threadlabs/looma/vue";
|
|
4
4
|
import { Editor, Extension, Node as Node$1, getText, getTextSerializersFromSchema, isNodeSelection, mergeAttributes, nodeInputRule, posToDOMRect } from "@tiptap/core";
|
|
5
5
|
import { Editor as Editor$1 } from "@tiptap/vue-3";
|
|
@@ -1536,6 +1536,281 @@ function validLinkHref(value) {
|
|
|
1536
1536
|
}
|
|
1537
1537
|
}
|
|
1538
1538
|
//#endregion
|
|
1539
|
+
//#region src/markdown/index.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* Callout tones a document stores, in the order the editor offers them. The Callout component also
|
|
1542
|
+
* takes `neutral` and `success`, which Markdown and the editor store as `note`.
|
|
1543
|
+
*/
|
|
1544
|
+
var LOOMA_CALLOUT_TONES = [
|
|
1545
|
+
"note",
|
|
1546
|
+
"tip",
|
|
1547
|
+
"info",
|
|
1548
|
+
"important",
|
|
1549
|
+
"warning",
|
|
1550
|
+
"danger"
|
|
1551
|
+
];
|
|
1552
|
+
/** The hues chips and swatches take as their tone, written `:chip[Label]{tone=green}`. */
|
|
1553
|
+
var LOOMA_CHIP_TONES = [
|
|
1554
|
+
"neutral",
|
|
1555
|
+
"blue",
|
|
1556
|
+
"green",
|
|
1557
|
+
"yellow",
|
|
1558
|
+
"red",
|
|
1559
|
+
"purple"
|
|
1560
|
+
];
|
|
1561
|
+
/**
|
|
1562
|
+
* Keeps a chip tone within the palette.
|
|
1563
|
+
* @failure Unknown values become `neutral`.
|
|
1564
|
+
*/
|
|
1565
|
+
function normalizeLoomaChipTone(value) {
|
|
1566
|
+
return LOOMA_CHIP_TONES.includes(value) ? value : "neutral";
|
|
1567
|
+
}
|
|
1568
|
+
[...LOOMA_CALLOUT_TONES];
|
|
1569
|
+
/**
|
|
1570
|
+
* The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
|
|
1571
|
+
* marks, with every other run of characters one hyphen.
|
|
1572
|
+
* @failure Text with no letters or numbers, such as only emoji, derives `heading`.
|
|
1573
|
+
*/
|
|
1574
|
+
function loomaHeadingSlug(text) {
|
|
1575
|
+
return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Gives every heading of a document its anchor, in document order.
|
|
1579
|
+
* @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
|
|
1580
|
+
* its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
|
|
1581
|
+
*/
|
|
1582
|
+
function assignLoomaHeadingIds(headings) {
|
|
1583
|
+
const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
|
|
1584
|
+
const used = /* @__PURE__ */ new Set();
|
|
1585
|
+
return headings.map(({ text, id: custom }) => {
|
|
1586
|
+
let id = custom || loomaHeadingSlug(text);
|
|
1587
|
+
if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
|
|
1588
|
+
const stem = id;
|
|
1589
|
+
let suffix = 1;
|
|
1590
|
+
while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
|
|
1591
|
+
id = `${stem}-${suffix}`;
|
|
1592
|
+
}
|
|
1593
|
+
used.add(id);
|
|
1594
|
+
return id;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/editor/extensions/chip.ts
|
|
1599
|
+
var CHIP_PLACEHOLDER = "Set a label";
|
|
1600
|
+
/** Keep imported or programmatic colors within the supported palette. */
|
|
1601
|
+
function normalizeLoomaChipColor(value) {
|
|
1602
|
+
return normalizeLoomaChipTone(value);
|
|
1603
|
+
}
|
|
1604
|
+
Node$1.create({
|
|
1605
|
+
name: "loomaChip",
|
|
1606
|
+
group: "inline",
|
|
1607
|
+
inline: true,
|
|
1608
|
+
atom: true,
|
|
1609
|
+
selectable: true,
|
|
1610
|
+
addAttributes() {
|
|
1611
|
+
return {
|
|
1612
|
+
label: {
|
|
1613
|
+
default: "",
|
|
1614
|
+
parseHTML: (element) => element.getAttribute("data-label") ?? (element.hasAttribute("data-placeholder") ? "" : element.textContent ?? "")
|
|
1615
|
+
},
|
|
1616
|
+
color: {
|
|
1617
|
+
default: "neutral",
|
|
1618
|
+
parseHTML: (element) => normalizeLoomaChipColor(element.getAttribute("data-color"))
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
},
|
|
1622
|
+
parseHTML() {
|
|
1623
|
+
return [{ tag: "span[data-looma-chip]" }];
|
|
1624
|
+
},
|
|
1625
|
+
renderHTML({ node }) {
|
|
1626
|
+
const label = String(node.attrs.label ?? "");
|
|
1627
|
+
return [
|
|
1628
|
+
"span",
|
|
1629
|
+
{
|
|
1630
|
+
"data-looma-chip": "",
|
|
1631
|
+
"data-label": label,
|
|
1632
|
+
"data-color": normalizeLoomaChipColor(node.attrs.color),
|
|
1633
|
+
...label ? {} : { "data-placeholder": "" }
|
|
1634
|
+
},
|
|
1635
|
+
label || CHIP_PLACEHOLDER
|
|
1636
|
+
];
|
|
1637
|
+
},
|
|
1638
|
+
renderText({ node }) {
|
|
1639
|
+
return String(node.attrs.label ?? "");
|
|
1640
|
+
},
|
|
1641
|
+
addNodeView() {
|
|
1642
|
+
return ({ node, editor }) => {
|
|
1643
|
+
const dom = document.createElement("span");
|
|
1644
|
+
dom.dataset.loomaChip = "";
|
|
1645
|
+
dom.contentEditable = "false";
|
|
1646
|
+
dom.setAttribute("role", "button");
|
|
1647
|
+
dom.setAttribute("aria-disabled", String(!editor.isEditable));
|
|
1648
|
+
dom.tabIndex = 0;
|
|
1649
|
+
const update = (next) => {
|
|
1650
|
+
if (next.type !== node.type) return false;
|
|
1651
|
+
const label = String(next.attrs.label ?? "");
|
|
1652
|
+
if (dom.dataset.label !== label) {
|
|
1653
|
+
dom.dataset.label = label;
|
|
1654
|
+
dom.textContent = label || CHIP_PLACEHOLDER;
|
|
1655
|
+
dom.setAttribute("aria-label", label ? `Edit chip: ${label}` : "Set chip label");
|
|
1656
|
+
if (label) delete dom.dataset.placeholder;
|
|
1657
|
+
else dom.dataset.placeholder = "";
|
|
1658
|
+
}
|
|
1659
|
+
const color = normalizeLoomaChipColor(next.attrs.color);
|
|
1660
|
+
if (dom.dataset.color !== color) dom.dataset.color = color;
|
|
1661
|
+
return true;
|
|
1662
|
+
};
|
|
1663
|
+
update(node);
|
|
1664
|
+
return {
|
|
1665
|
+
dom,
|
|
1666
|
+
update,
|
|
1667
|
+
stopEvent: (event) => event.type === "click" || event.type === "keydown"
|
|
1668
|
+
};
|
|
1669
|
+
};
|
|
1670
|
+
},
|
|
1671
|
+
addCommands() {
|
|
1672
|
+
return { insertLoomaChip: (attributes = {}) => ({ commands, state }) => {
|
|
1673
|
+
const selection = state.selection;
|
|
1674
|
+
const label = attributes.label ?? state.doc.textBetween(selection.from, selection.to);
|
|
1675
|
+
return commands.insertContent({
|
|
1676
|
+
type: this.name,
|
|
1677
|
+
attrs: {
|
|
1678
|
+
label,
|
|
1679
|
+
color: normalizeLoomaChipColor(attributes.color)
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
} };
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
//#endregion
|
|
1686
|
+
//#region src/editor/chip-editing.ts
|
|
1687
|
+
var LOOMA_CHIP_EDITING_IDLE = Object.freeze({
|
|
1688
|
+
open: false,
|
|
1689
|
+
label: "",
|
|
1690
|
+
color: "neutral"
|
|
1691
|
+
});
|
|
1692
|
+
function createChipEditing(options) {
|
|
1693
|
+
const { editor } = options;
|
|
1694
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1695
|
+
let state = LOOMA_CHIP_EDITING_IDLE;
|
|
1696
|
+
let element = null;
|
|
1697
|
+
const set = (next) => {
|
|
1698
|
+
state = next;
|
|
1699
|
+
listeners.forEach((listener) => listener());
|
|
1700
|
+
};
|
|
1701
|
+
const close = () => {
|
|
1702
|
+
element = null;
|
|
1703
|
+
if (state.open) set({
|
|
1704
|
+
...state,
|
|
1705
|
+
open: false
|
|
1706
|
+
});
|
|
1707
|
+
};
|
|
1708
|
+
const change = (changes) => {
|
|
1709
|
+
if (!element || editor.isDestroyed || !element.isConnected) return close();
|
|
1710
|
+
const position = editor.view.posAtDOM(element, 0);
|
|
1711
|
+
const node = editor.state.doc.nodeAt(position);
|
|
1712
|
+
if (node?.type.name !== "loomaChip") return close();
|
|
1713
|
+
set({
|
|
1714
|
+
...state,
|
|
1715
|
+
...changes
|
|
1716
|
+
});
|
|
1717
|
+
editor.view.dispatch(editor.state.tr.setNodeMarkup(position, void 0, {
|
|
1718
|
+
...node.attrs,
|
|
1719
|
+
...changes
|
|
1720
|
+
}));
|
|
1721
|
+
};
|
|
1722
|
+
return {
|
|
1723
|
+
get state() {
|
|
1724
|
+
return state;
|
|
1725
|
+
},
|
|
1726
|
+
get element() {
|
|
1727
|
+
return element;
|
|
1728
|
+
},
|
|
1729
|
+
subscribe(listener) {
|
|
1730
|
+
listeners.add(listener);
|
|
1731
|
+
return () => {
|
|
1732
|
+
listeners.delete(listener);
|
|
1733
|
+
};
|
|
1734
|
+
},
|
|
1735
|
+
open(position) {
|
|
1736
|
+
if (!options.isEditable() || editor.isDestroyed) return false;
|
|
1737
|
+
const node = editor.state.doc.nodeAt(position);
|
|
1738
|
+
const dom = editor.view.nodeDOM(position);
|
|
1739
|
+
if (node?.type.name !== "loomaChip" || !(dom instanceof HTMLElement)) return false;
|
|
1740
|
+
element = dom;
|
|
1741
|
+
set({
|
|
1742
|
+
open: true,
|
|
1743
|
+
label: String(node.attrs.label ?? ""),
|
|
1744
|
+
color: normalizeLoomaChipColor(node.attrs.color)
|
|
1745
|
+
});
|
|
1746
|
+
return true;
|
|
1747
|
+
},
|
|
1748
|
+
setLabel: (label) => change({ label }),
|
|
1749
|
+
setColor: (color) => change({ color }),
|
|
1750
|
+
close
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
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
|
+
//#endregion
|
|
1539
1814
|
//#region src/editor/extensions/relative-links.ts
|
|
1540
1815
|
/** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
|
|
1541
1816
|
function siteRelativeHref(href, baseUrl) {
|
|
@@ -2056,34 +2331,6 @@ function formatEditorShortcut(keys, apple) {
|
|
|
2056
2331
|
].filter(Boolean).join("+");
|
|
2057
2332
|
}
|
|
2058
2333
|
var isApplePlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent);
|
|
2059
|
-
/**
|
|
2060
|
-
* The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
|
|
2061
|
-
* marks, with every other run of characters one hyphen.
|
|
2062
|
-
* @failure Text with no letters or numbers, such as only emoji, derives `heading`.
|
|
2063
|
-
*/
|
|
2064
|
-
function loomaHeadingSlug(text) {
|
|
2065
|
-
return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
|
|
2066
|
-
}
|
|
2067
|
-
/**
|
|
2068
|
-
* Gives every heading of a document its anchor, in document order.
|
|
2069
|
-
* @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
|
|
2070
|
-
* its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
|
|
2071
|
-
*/
|
|
2072
|
-
function assignLoomaHeadingIds(headings) {
|
|
2073
|
-
const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
|
|
2074
|
-
const used = /* @__PURE__ */ new Set();
|
|
2075
|
-
return headings.map(({ text, id: custom }) => {
|
|
2076
|
-
let id = custom || loomaHeadingSlug(text);
|
|
2077
|
-
if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
|
|
2078
|
-
const stem = id;
|
|
2079
|
-
let suffix = 1;
|
|
2080
|
-
while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
|
|
2081
|
-
id = `${stem}-${suffix}`;
|
|
2082
|
-
}
|
|
2083
|
-
used.add(id);
|
|
2084
|
-
return id;
|
|
2085
|
-
});
|
|
2086
|
-
}
|
|
2087
2334
|
//#endregion
|
|
2088
2335
|
//#region src/editor/extensions/expand.ts
|
|
2089
2336
|
/**
|
|
@@ -3215,9 +3462,8 @@ var LoomaEditor = defineComponent({
|
|
|
3215
3462
|
chipEditorRoot.value = element instanceof HTMLElement ? element : null;
|
|
3216
3463
|
};
|
|
3217
3464
|
const chipInput = () => chipEditorRoot.value?.querySelector("[data-chip-label]");
|
|
3218
|
-
|
|
3219
|
-
const
|
|
3220
|
-
const chipColor = ref("neutral");
|
|
3465
|
+
let chipEditing;
|
|
3466
|
+
const chipState = shallowRef(LOOMA_CHIP_EDITING_IDLE);
|
|
3221
3467
|
const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
3222
3468
|
const tooltipFor = ref("");
|
|
3223
3469
|
const tooltipLabel = ref("");
|
|
@@ -3270,11 +3516,7 @@ var LoomaEditor = defineComponent({
|
|
|
3270
3516
|
let imagePopup = null;
|
|
3271
3517
|
const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
|
|
3272
3518
|
const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
|
|
3273
|
-
const activeRangeActions = (instance) =>
|
|
3274
|
-
const selection = instance.state.selection;
|
|
3275
|
-
if (!(selection instanceof TextSelection) || !selection.empty) return [];
|
|
3276
|
-
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);
|
|
3277
|
-
};
|
|
3519
|
+
const activeRangeActions = (instance) => rangeActionsAtCaret(instance.state, props.rangeActions);
|
|
3278
3520
|
const formattingActive = () => formatting?.active() ?? false;
|
|
3279
3521
|
const editorStateVersion = ref(0);
|
|
3280
3522
|
const mobileToolbarMode = ref("formatting");
|
|
@@ -3320,39 +3562,16 @@ var LoomaEditor = defineComponent({
|
|
|
3320
3562
|
menuStyle: {},
|
|
3321
3563
|
capabilities: { ...EMPTY_CAPABILITIES }
|
|
3322
3564
|
});
|
|
3323
|
-
const openChipEditor = (
|
|
3324
|
-
if (!
|
|
3325
|
-
const node = instance.state.doc.nodeAt(position);
|
|
3326
|
-
const element = instance.view.nodeDOM(position);
|
|
3327
|
-
if (node?.type.name !== "loomaChip" || !(element instanceof HTMLElement)) return;
|
|
3565
|
+
const openChipEditor = (_instance, position) => {
|
|
3566
|
+
if (!chipEditing?.open(position)) return;
|
|
3328
3567
|
root.value?.querySelector(`#${chipAnchorId}`)?.removeAttribute("id");
|
|
3329
|
-
element.id = chipAnchorId;
|
|
3330
|
-
chipLabel.value = String(node.attrs.label ?? "");
|
|
3331
|
-
chipColor.value = normalizeLoomaChipColor(node.attrs.color);
|
|
3332
|
-
chipOpen.value = true;
|
|
3568
|
+
chipEditing.element.id = chipAnchorId;
|
|
3333
3569
|
nextTick(() => chipInput()?.focus());
|
|
3334
3570
|
};
|
|
3335
|
-
const changeChip = (changes) => {
|
|
3336
|
-
const instance = editor.value;
|
|
3337
|
-
const element = root.value?.querySelector(`#${chipAnchorId}`);
|
|
3338
|
-
if (!instance || !element) {
|
|
3339
|
-
chipOpen.value = false;
|
|
3340
|
-
return;
|
|
3341
|
-
}
|
|
3342
|
-
const position = instance.view.posAtDOM(element, 0);
|
|
3343
|
-
const node = instance.state.doc.nodeAt(position);
|
|
3344
|
-
if (node?.type.name !== "loomaChip") {
|
|
3345
|
-
chipOpen.value = false;
|
|
3346
|
-
return;
|
|
3347
|
-
}
|
|
3348
|
-
instance.view.dispatch(instance.state.tr.setNodeMarkup(position, void 0, {
|
|
3349
|
-
...node.attrs,
|
|
3350
|
-
...changes
|
|
3351
|
-
}));
|
|
3352
|
-
};
|
|
3353
3571
|
const closeChip = (focusChip = false) => {
|
|
3354
|
-
|
|
3355
|
-
|
|
3572
|
+
const element = chipEditing?.element;
|
|
3573
|
+
chipEditing?.close();
|
|
3574
|
+
if (focusChip) element?.focus();
|
|
3356
3575
|
};
|
|
3357
3576
|
const found = reactive(/* @__PURE__ */ new Set());
|
|
3358
3577
|
const answers = (name) => {
|
|
@@ -3565,6 +3784,14 @@ var LoomaEditor = defineComponent({
|
|
|
3565
3784
|
linkState.value = engine.state;
|
|
3566
3785
|
});
|
|
3567
3786
|
linkEditing = engine;
|
|
3787
|
+
const chips = createChipEditing({
|
|
3788
|
+
editor: session.editor,
|
|
3789
|
+
isEditable: () => props.editable
|
|
3790
|
+
});
|
|
3791
|
+
chips.subscribe(() => {
|
|
3792
|
+
chipState.value = chips.state;
|
|
3793
|
+
});
|
|
3794
|
+
chipEditing = chips;
|
|
3568
3795
|
const imageUploads = createImageUploads({
|
|
3569
3796
|
editor: session.editor,
|
|
3570
3797
|
upload: () => async (file, init) => {
|
|
@@ -3597,6 +3824,7 @@ var LoomaEditor = defineComponent({
|
|
|
3597
3824
|
const previous = session;
|
|
3598
3825
|
const content = edited ? previous.editor.getJSON() : props.modelValue;
|
|
3599
3826
|
linkEditing?.destroy();
|
|
3827
|
+
chipEditing?.close();
|
|
3600
3828
|
formatting?.destroy();
|
|
3601
3829
|
uploads?.cancel();
|
|
3602
3830
|
previous.destroy();
|
|
@@ -3721,7 +3949,7 @@ var LoomaEditor = defineComponent({
|
|
|
3721
3949
|
root.value?.querySelectorAll("[data-looma-chip]").forEach((chip) => {
|
|
3722
3950
|
chip.setAttribute("aria-disabled", String(!editable));
|
|
3723
3951
|
});
|
|
3724
|
-
if (!editable)
|
|
3952
|
+
if (!editable) chipEditing?.close();
|
|
3725
3953
|
});
|
|
3726
3954
|
watch([
|
|
3727
3955
|
() => linkState.value.surface === "popover",
|
|
@@ -4342,40 +4570,22 @@ var LoomaEditor = defineComponent({
|
|
|
4342
4570
|
onClick: run
|
|
4343
4571
|
}, () => loomaIcon(icon));
|
|
4344
4572
|
};
|
|
4345
|
-
const renderRangeActions = (instance) => {
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
groups.set(action.id, group);
|
|
4573
|
+
const renderRangeActions = (instance) => groupRangeActions(activeRangeActions(instance)).map(({ id, label, detail }) => h(Button, {
|
|
4574
|
+
key: id,
|
|
4575
|
+
"aria-label": label,
|
|
4576
|
+
type: "button",
|
|
4577
|
+
variant: "ghost",
|
|
4578
|
+
tone: "neutral",
|
|
4579
|
+
size: "sm",
|
|
4580
|
+
onPointerdown: (event) => event.preventDefault(),
|
|
4581
|
+
onClick: () => {
|
|
4582
|
+
emit("rangeAction", detail);
|
|
4583
|
+
formattingPopup?.hide();
|
|
4357
4584
|
}
|
|
4358
|
-
|
|
4359
|
-
key: id,
|
|
4360
|
-
"aria-label": group.label,
|
|
4361
|
-
type: "button",
|
|
4362
|
-
variant: "ghost",
|
|
4363
|
-
tone: "neutral",
|
|
4364
|
-
size: "sm",
|
|
4365
|
-
onPointerdown: (event) => event.preventDefault(),
|
|
4366
|
-
onClick: () => {
|
|
4367
|
-
emit("rangeAction", {
|
|
4368
|
-
id,
|
|
4369
|
-
ranges: [...group.ranges.values()]
|
|
4370
|
-
});
|
|
4371
|
-
formattingPopup?.hide();
|
|
4372
|
-
}
|
|
4373
|
-
}, () => group.label));
|
|
4374
|
-
};
|
|
4585
|
+
}, () => label));
|
|
4375
4586
|
const renderSelectionActions = (instance) => {
|
|
4376
|
-
|
|
4377
|
-
if (
|
|
4378
|
-
if (!(selection instanceof TextSelection || selection instanceof AllSelection)) return [];
|
|
4587
|
+
if (instance.state.selection.empty) return renderRangeActions(instance);
|
|
4588
|
+
if (!selectsPassage(instance.state)) return [];
|
|
4379
4589
|
return props.selectionActions.map((action) => h(Button, {
|
|
4380
4590
|
key: action.id,
|
|
4381
4591
|
"aria-label": action.label,
|
|
@@ -4385,17 +4595,9 @@ var LoomaEditor = defineComponent({
|
|
|
4385
4595
|
size: "sm",
|
|
4386
4596
|
onPointerdown: (event) => event.preventDefault(),
|
|
4387
4597
|
onClick: () => {
|
|
4388
|
-
const
|
|
4389
|
-
if (
|
|
4390
|
-
|
|
4391
|
-
emit("selectionAction", {
|
|
4392
|
-
id: action.id,
|
|
4393
|
-
from,
|
|
4394
|
-
to,
|
|
4395
|
-
text: doc.textBetween(from, to, "\n"),
|
|
4396
|
-
before: doc.textBetween(Math.max(0, from - 48), from, "\n"),
|
|
4397
|
-
after: doc.textBetween(to, Math.min(doc.content.size, to + 48), "\n")
|
|
4398
|
-
});
|
|
4598
|
+
const detail = selectionActionDetail(instance.state, action.id);
|
|
4599
|
+
if (!detail) return;
|
|
4600
|
+
emit("selectionAction", detail);
|
|
4399
4601
|
formattingPopup?.hide();
|
|
4400
4602
|
}
|
|
4401
4603
|
}, () => action.label));
|
|
@@ -4691,24 +4893,16 @@ var LoomaEditor = defineComponent({
|
|
|
4691
4893
|
}, [loomaIcon("image"), h("span", "Drop image to upload")]) : null,
|
|
4692
4894
|
h(Popover, {
|
|
4693
4895
|
class: "looma-editor__chip-popover",
|
|
4694
|
-
open:
|
|
4896
|
+
open: chipState.value.open,
|
|
4695
4897
|
for: chipAnchorId,
|
|
4696
4898
|
placement: "bottom-start",
|
|
4697
|
-
onClose: () =>
|
|
4698
|
-
chipOpen.value = false;
|
|
4699
|
-
}
|
|
4899
|
+
onClose: () => chipEditing?.close()
|
|
4700
4900
|
}, () => h(EditorChipEditor, {
|
|
4701
4901
|
ref: setChipEditorRoot,
|
|
4702
|
-
label:
|
|
4703
|
-
color:
|
|
4704
|
-
onLabel: (event) =>
|
|
4705
|
-
|
|
4706
|
-
changeChip({ label: chipLabel.value });
|
|
4707
|
-
},
|
|
4708
|
-
onColor: (event) => {
|
|
4709
|
-
chipColor.value = event.detail.color;
|
|
4710
|
-
changeChip({ color: event.detail.color });
|
|
4711
|
-
},
|
|
4902
|
+
label: chipState.value.label,
|
|
4903
|
+
color: chipState.value.color,
|
|
4904
|
+
onLabel: (event) => chipEditing?.setLabel(event.detail.label),
|
|
4905
|
+
onColor: (event) => chipEditing?.setColor(event.detail.color),
|
|
4712
4906
|
onDone: () => closeChip(true)
|
|
4713
4907
|
})),
|
|
4714
4908
|
h(Popover, {
|