@threadlabs/looma 0.28.9 → 0.28.11
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,9 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
/**
|
|
3
|
+
* Applies a block menu action to the top-level block holding the caret, then focuses the editor:
|
|
4
|
+
* insert a paragraph below, duplicate the block, or delete it. Deleting the only block leaves an
|
|
5
|
+
* empty paragraph.
|
|
6
|
+
*
|
|
7
|
+
* @failure Returns false, and changes nothing, for an unknown action or a caret outside any block.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runBlockAction(editor: Editor, action: string): boolean;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Editor, Range } from "@tiptap/core";
|
|
6
6
|
import type { TableContextMenuActionEventDetail } from "../table-context-menu.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type TableOverlayActionEventDetail } from "../table-overlay.js";
|
|
8
8
|
import { type TableCellAlignment, type TableCellBackground } from "./table-formatting.js";
|
|
9
9
|
/**
|
|
10
10
|
* Bounded initial shape for table insertion.
|
|
@@ -120,3 +120,9 @@ export declare function normalizeActiveTableColumnWidths(editor: Editor, tableEl
|
|
|
120
120
|
* return false before dispatching a transaction.
|
|
121
121
|
*/
|
|
122
122
|
export declare function handleTableOverlayAction(editor: Editor, detail: TableOverlayActionEventDetail): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Moves the caret to the cell an overlay action names when the action comes from a table other
|
|
125
|
+
* than the selected one, so table commands act on that table. A cell menu also selects its cell,
|
|
126
|
+
* unless that cell is already part of the selection.
|
|
127
|
+
*/
|
|
128
|
+
export declare function focusTableOverlayTarget(editor: Editor, table: HTMLTableElement, detail: TableOverlayActionEventDetail): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
/** A destination supplied by the host application, such as a page or record. */
|
|
3
|
+
export interface LoomaLinkTarget {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
detail?: string;
|
|
7
|
+
href: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Finds host records for a link without teaching the editor how they are stored.
|
|
11
|
+
* @ownership The host checks access and returns durable, openable href values.
|
|
12
|
+
* @failure A rejection leaves the existing document untouched and shows a retryable search error.
|
|
13
|
+
*/
|
|
14
|
+
export type LoomaLinkSearch = (query: string) => Promise<LoomaLinkTarget[]>;
|
|
15
|
+
/**
|
|
16
|
+
* Describes a saved host link so a renamed record can show its current label.
|
|
17
|
+
* @ownership The host resolves identity and access; Looma keeps the saved href as is.
|
|
18
|
+
* @failure A rejection leaves the original href visible and editable.
|
|
19
|
+
*/
|
|
20
|
+
export type LoomaLinkResolve = (href: string) => Promise<LoomaLinkTarget | null>;
|
|
21
|
+
/** The link form's state and the resolved target of the link at the caret. */
|
|
22
|
+
export interface LoomaLinkEditingState {
|
|
23
|
+
/** Where the form shows: nowhere, in the toolbar's popover, or in place of the link card. */
|
|
24
|
+
readonly surface: "none" | "popover" | "context";
|
|
25
|
+
/** The range the form links, and whether it was already a link when the form opened. */
|
|
26
|
+
readonly range: {
|
|
27
|
+
readonly from: number;
|
|
28
|
+
readonly to: number;
|
|
29
|
+
readonly existing: boolean;
|
|
30
|
+
} | null;
|
|
31
|
+
readonly mode: "target" | "url";
|
|
32
|
+
readonly query: string;
|
|
33
|
+
readonly href: string;
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly textEditable: boolean;
|
|
36
|
+
readonly newTab: boolean;
|
|
37
|
+
readonly error: string;
|
|
38
|
+
readonly results: readonly LoomaLinkTarget[];
|
|
39
|
+
readonly searching: boolean;
|
|
40
|
+
readonly selected: LoomaLinkTarget | null;
|
|
41
|
+
/** What the link at the caret resolves to, for the link card. */
|
|
42
|
+
readonly current: LoomaLinkTarget | null;
|
|
43
|
+
}
|
|
44
|
+
export interface LoomaLinkEditingOptions {
|
|
45
|
+
editor: Editor;
|
|
46
|
+
/** Read on each use, so a host can change them while the editor runs. */
|
|
47
|
+
search: () => LoomaLinkSearch | null | undefined;
|
|
48
|
+
resolve: () => LoomaLinkResolve | null | undefined;
|
|
49
|
+
baseUrl: () => string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The editor's link form as a state machine: what the form holds, how it applies to the
|
|
53
|
+
* document as the reader types, and the host's search and resolve calls.
|
|
54
|
+
*
|
|
55
|
+
* @ownership The host owns presentation: where the form shows, focus, and which selection it
|
|
56
|
+
* opens on. This owns the form's state and the link mark it writes.
|
|
57
|
+
* @lifecycle Listens to the editor's transactions until `destroy()`; results of a search or lookup
|
|
58
|
+
* that finish after a newer one, or after `destroy()`, are dropped.
|
|
59
|
+
*/
|
|
60
|
+
export interface LoomaLinkEditing {
|
|
61
|
+
readonly state: LoomaLinkEditingState;
|
|
62
|
+
/** Calls `listener` after every state change; returns the unsubscribe function. */
|
|
63
|
+
subscribe(listener: () => void): () => void;
|
|
64
|
+
/** Opens the form on `from`–`to`, filled from the link there if there is one. */
|
|
65
|
+
open(from: number, to: number, surface: "popover" | "context"): void;
|
|
66
|
+
setDestination(value: string): void;
|
|
67
|
+
clear(): void;
|
|
68
|
+
choose(target: LoomaLinkTarget): void;
|
|
69
|
+
setNewTab(newTab: boolean): void;
|
|
70
|
+
setText(text: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Writes the form's link to its range without moving focus.
|
|
73
|
+
* @failure Returns false and leaves the document unchanged when the destination or text is
|
|
74
|
+
* missing or invalid; `reportError` also sets the form's error.
|
|
75
|
+
*/
|
|
76
|
+
apply(reportError?: boolean): boolean;
|
|
77
|
+
close(): void;
|
|
78
|
+
/** Removes the link at the caret. */
|
|
79
|
+
remove(): void;
|
|
80
|
+
destroy(): void;
|
|
81
|
+
}
|
|
82
|
+
/** The status line above search results: progress, a hint, or an empty result. */
|
|
83
|
+
export declare function linkFormStatus(state: LoomaLinkEditingState): string;
|
|
84
|
+
export declare const LOOMA_LINK_EDITING_IDLE: LoomaLinkEditingState;
|
|
85
|
+
export declare function createLinkEditing(options: LoomaLinkEditingOptions): LoomaLinkEditing;
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { type AnyExtension, type Editor, type JSONContent } from "@tiptap/core";
|
|
|
4
4
|
import { type PropType, type VNode } from "vue";
|
|
5
5
|
import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaMentionProvider, type LoomaCodeLanguages } from "@threadlabs/looma/editor";
|
|
6
6
|
import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
|
|
7
|
+
import { type LoomaLinkResolve, type LoomaLinkSearch } from "../../editor/link-editing.js";
|
|
7
8
|
/**
|
|
8
9
|
* Host upload result normalized into the editor's durable image descriptor.
|
|
9
10
|
* `url` becomes the stored `src`; optional dimensions preserve layout before
|
|
@@ -62,25 +63,7 @@ export interface LoomaEditorSelectionActionDetail {
|
|
|
62
63
|
* fresh defaults and invokes this factory when the inventory prop changes.
|
|
63
64
|
*/
|
|
64
65
|
export type LoomaEditorSlashCommands = LoomaSlashCommand[] | ((defaults: LoomaSlashCommand[]) => LoomaSlashCommand[]);
|
|
65
|
-
|
|
66
|
-
export interface LoomaLinkTarget {
|
|
67
|
-
id: string;
|
|
68
|
-
label: string;
|
|
69
|
-
detail?: string;
|
|
70
|
-
href: string;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Finds host records for a link without teaching the editor how they are stored.
|
|
74
|
-
* @ownership The host checks access and returns durable, openable href values.
|
|
75
|
-
* @failure A rejection leaves the existing document untouched and shows a retryable search error.
|
|
76
|
-
*/
|
|
77
|
-
export type LoomaLinkSearch = (query: string) => Promise<LoomaLinkTarget[]>;
|
|
78
|
-
/**
|
|
79
|
-
* Describes a saved host link so a renamed record can show its current label.
|
|
80
|
-
* @ownership The host resolves identity and access; Looma keeps the saved href as is.
|
|
81
|
-
* @failure A rejection leaves the original href visible and editable.
|
|
82
|
-
*/
|
|
83
|
-
export type LoomaLinkResolve = (href: string) => Promise<LoomaLinkTarget | null>;
|
|
66
|
+
export type { LoomaLinkResolve, LoomaLinkSearch, LoomaLinkTarget } from "../../editor/link-editing.js";
|
|
84
67
|
/**
|
|
85
68
|
* Turnkey Vue editor that composes Looma's framework-neutral Tiptap extensions
|
|
86
69
|
* with declarative menus, image delivery, and table interaction chrome.
|
package/vue/editor/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useId, watch } from "vue";
|
|
2
|
-
import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig
|
|
2
|
+
import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig } from "@threadlabs/looma/editor";
|
|
3
3
|
import { Button, Callout, Card, Checkbox, Cluster, Disclosure, EditorBlockActions, 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, 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";
|
|
@@ -7,6 +7,8 @@ import { closeHistory } from "@tiptap/pm/history";
|
|
|
7
7
|
import { AllSelection, NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
8
8
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
9
9
|
import { createLowlight } from "lowlight";
|
|
10
|
+
import "@tiptap/pm/tables";
|
|
11
|
+
import "@tiptap/pm/transform";
|
|
10
12
|
import { DOMParser, DOMSerializer, Fragment as Fragment$1 } from "@tiptap/pm/model";
|
|
11
13
|
export * from "@threadlabs/looma/editor";
|
|
12
14
|
//#region src/vue/editor/primitives.ts
|
|
@@ -1093,32 +1095,79 @@ function createCodeHighlighting(codeLanguages, onGrammarsLoaded) {
|
|
|
1093
1095
|
};
|
|
1094
1096
|
}
|
|
1095
1097
|
//#endregion
|
|
1096
|
-
//#region src/editor/
|
|
1097
|
-
/**
|
|
1098
|
-
|
|
1099
|
-
|
|
1098
|
+
//#region src/editor/block-actions.ts
|
|
1099
|
+
/**
|
|
1100
|
+
* Applies a block menu action to the top-level block holding the caret, then focuses the editor:
|
|
1101
|
+
* insert a paragraph below, duplicate the block, or delete it. Deleting the only block leaves an
|
|
1102
|
+
* empty paragraph.
|
|
1103
|
+
*
|
|
1104
|
+
* @failure Returns false, and changes nothing, for an unknown action or a caret outside any block.
|
|
1105
|
+
*/
|
|
1106
|
+
function runBlockAction(editor, action) {
|
|
1107
|
+
const head = editor.state.selection.$head;
|
|
1108
|
+
if (head.depth === 0) return false;
|
|
1109
|
+
const { state } = editor;
|
|
1110
|
+
const position = head.before(1);
|
|
1111
|
+
const block = state.doc.nodeAt(position);
|
|
1112
|
+
if (!block) return false;
|
|
1113
|
+
const end = position + block.nodeSize;
|
|
1114
|
+
const transaction = state.tr;
|
|
1115
|
+
let focusAt = end + 1;
|
|
1116
|
+
if (action === "insert-below") transaction.insert(end, state.schema.nodes.paragraph.create());
|
|
1117
|
+
else if (action === "duplicate") transaction.insert(end, block);
|
|
1118
|
+
else if (action === "delete") {
|
|
1119
|
+
if (state.doc.childCount === 1) {
|
|
1120
|
+
transaction.replaceWith(position, end, state.schema.nodes.paragraph.create());
|
|
1121
|
+
focusAt = 1;
|
|
1122
|
+
} else {
|
|
1123
|
+
transaction.delete(position, end);
|
|
1124
|
+
focusAt = Math.min(position + 1, transaction.doc.content.size);
|
|
1125
|
+
}
|
|
1126
|
+
} else return false;
|
|
1127
|
+
transaction.setSelection(TextSelection.near(transaction.doc.resolve(Math.min(focusAt, transaction.doc.content.size))));
|
|
1128
|
+
editor.view.dispatch(transaction);
|
|
1129
|
+
editor.commands.focus();
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region src/editor/table-overlay.ts
|
|
1134
|
+
function mapTableCells(table) {
|
|
1135
|
+
const rows = Array.from(table.rows);
|
|
1136
|
+
const grid = [];
|
|
1137
|
+
const coordinates = /* @__PURE__ */ new Map();
|
|
1138
|
+
rows.forEach((row, rowIndex) => {
|
|
1139
|
+
grid[rowIndex] ??= [];
|
|
1140
|
+
let columnIndex = 0;
|
|
1141
|
+
for (const cell of Array.from(row.cells)) {
|
|
1142
|
+
while (grid[rowIndex]?.[columnIndex]) columnIndex += 1;
|
|
1143
|
+
coordinates.set(cell, {
|
|
1144
|
+
rowIndex,
|
|
1145
|
+
columnIndex
|
|
1146
|
+
});
|
|
1147
|
+
const rowSpan = cell.rowSpan === 0 ? rows.length - rowIndex : Math.max(1, cell.rowSpan);
|
|
1148
|
+
const columnSpan = Math.max(1, cell.colSpan);
|
|
1149
|
+
for (let rowOffset = 0; rowOffset < rowSpan; rowOffset += 1) {
|
|
1150
|
+
const gridRow = grid[rowIndex + rowOffset] ??= [];
|
|
1151
|
+
for (let columnOffset = 0; columnOffset < columnSpan; columnOffset += 1) gridRow[columnIndex + columnOffset] = cell;
|
|
1152
|
+
}
|
|
1153
|
+
columnIndex += columnSpan;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
return {
|
|
1157
|
+
grid,
|
|
1158
|
+
coordinates
|
|
1159
|
+
};
|
|
1100
1160
|
}
|
|
1101
1161
|
/**
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1162
|
+
* Resolves a logical grid coordinate to its owning DOM cell.
|
|
1163
|
+
* A spanning cell can therefore be returned for more than one coordinate; this
|
|
1164
|
+
* is intentional and prevents overlay actions from inventing nonexistent cells.
|
|
1104
1165
|
*
|
|
1105
|
-
* @
|
|
1166
|
+
* @contract Coordinates outside the expanded table grid return null; coordinates
|
|
1167
|
+
* covered by a span resolve to the single DOM cell that owns them.
|
|
1106
1168
|
*/
|
|
1107
|
-
function
|
|
1108
|
-
|
|
1109
|
-
if (!href || /[\u0000-\u001f\u007f]/.test(href)) return null;
|
|
1110
|
-
if (/^(\/|#|\?)/.test(href)) return href;
|
|
1111
|
-
try {
|
|
1112
|
-
const url = new URL(href);
|
|
1113
|
-
return [
|
|
1114
|
-
"http:",
|
|
1115
|
-
"https:",
|
|
1116
|
-
"mailto:",
|
|
1117
|
-
"tel:"
|
|
1118
|
-
].includes(url.protocol) ? href : null;
|
|
1119
|
-
} catch {
|
|
1120
|
-
return null;
|
|
1121
|
-
}
|
|
1169
|
+
function resolveTableCellAt(table, rowIndex, columnIndex) {
|
|
1170
|
+
return mapTableCells(table).grid[rowIndex]?.[columnIndex] ?? null;
|
|
1122
1171
|
}
|
|
1123
1172
|
//#endregion
|
|
1124
1173
|
//#region src/editor/selection-geometry.ts
|
|
@@ -1149,6 +1198,317 @@ function selectedTableCellElement(editor) {
|
|
|
1149
1198
|
return selectionElement(editor)?.closest("td, th");
|
|
1150
1199
|
}
|
|
1151
1200
|
//#endregion
|
|
1201
|
+
//#region src/editor/extensions/table-commands.ts
|
|
1202
|
+
/**
|
|
1203
|
+
* Moves the caret to the cell an overlay action names when the action comes from a table other
|
|
1204
|
+
* than the selected one, so table commands act on that table. A cell menu also selects its cell,
|
|
1205
|
+
* unless that cell is already part of the selection.
|
|
1206
|
+
*/
|
|
1207
|
+
function focusTableOverlayTarget(editor, table, detail) {
|
|
1208
|
+
const target = "rowIndex" in detail ? resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) : "fromIndex" in detail ? resolveTableCellAt(table, detail.action === "reorder-row" ? detail.fromIndex : 0, detail.action === "reorder-column" ? detail.fromIndex : 0) : resolveTableCellAt(table, detail.action.startsWith("add-row") ? Math.max(0, detail.boundaryIndex - 1) : 0, detail.action.startsWith("add-column") ? Math.max(0, detail.boundaryIndex - 1) : 0);
|
|
1209
|
+
if (target && selectedTableElement(editor) !== table) editor.chain().focus().setTextSelection(editor.view.posAtDOM(target, 0) + 1).run();
|
|
1210
|
+
if (detail.action !== "open-cell-menu") return;
|
|
1211
|
+
const selected = selectedTableCellElement(editor);
|
|
1212
|
+
const cell = resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) ?? selected;
|
|
1213
|
+
if (cell && selected !== cell && !cell.classList.contains("selectedCell")) editor.chain().focus().setTextSelection(editor.view.posAtDOM(cell, 0) + 1).run();
|
|
1214
|
+
else editor.commands.focus();
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/editor/content.ts
|
|
1218
|
+
/** Whether a controlled document already matches the editor, so applying it would only reset the selection. */
|
|
1219
|
+
function sameDocument(left, right) {
|
|
1220
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* A link destination the editor may store: a site-relative path, fragment, or query, or an
|
|
1224
|
+
* absolute http, https, mailto, or tel URL.
|
|
1225
|
+
*
|
|
1226
|
+
* @failure Returns null for empty input, control characters, other schemes, and unparseable URLs.
|
|
1227
|
+
*/
|
|
1228
|
+
function validLinkHref(value) {
|
|
1229
|
+
const href = value.trim();
|
|
1230
|
+
if (!href || /[\u0000-\u001f\u007f]/.test(href)) return null;
|
|
1231
|
+
if (/^(\/|#|\?)/.test(href)) return href;
|
|
1232
|
+
try {
|
|
1233
|
+
const url = new URL(href);
|
|
1234
|
+
return [
|
|
1235
|
+
"http:",
|
|
1236
|
+
"https:",
|
|
1237
|
+
"mailto:",
|
|
1238
|
+
"tel:"
|
|
1239
|
+
].includes(url.protocol) ? href : null;
|
|
1240
|
+
} catch {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/editor/extensions/relative-links.ts
|
|
1246
|
+
/** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
|
|
1247
|
+
function siteRelativeHref(href, baseUrl) {
|
|
1248
|
+
if (!/^(https?:\/\/|\/\/)/i.test(href.trim())) return href;
|
|
1249
|
+
try {
|
|
1250
|
+
const base = new URL(baseUrl);
|
|
1251
|
+
const url = new URL(href, base);
|
|
1252
|
+
if (!/^https?:$/.test(base.protocol) || url.origin !== base.origin || url.username || url.password) return href;
|
|
1253
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
1254
|
+
} catch {
|
|
1255
|
+
return href;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/editor/link-editing.ts
|
|
1260
|
+
/** The status line above search results: progress, a hint, or an empty result. */
|
|
1261
|
+
function linkFormStatus(state) {
|
|
1262
|
+
if (state.searching) return "Searching…";
|
|
1263
|
+
if (state.selected) return "";
|
|
1264
|
+
if (state.query.trim().length < 2) return "Type at least two letters to search.";
|
|
1265
|
+
return state.results.length || state.error ? "" : "No results found.";
|
|
1266
|
+
}
|
|
1267
|
+
var LOOMA_LINK_EDITING_IDLE = Object.freeze({
|
|
1268
|
+
surface: "none",
|
|
1269
|
+
range: null,
|
|
1270
|
+
mode: "url",
|
|
1271
|
+
query: "",
|
|
1272
|
+
href: "",
|
|
1273
|
+
text: "",
|
|
1274
|
+
textEditable: false,
|
|
1275
|
+
newTab: true,
|
|
1276
|
+
error: "",
|
|
1277
|
+
results: [],
|
|
1278
|
+
searching: false,
|
|
1279
|
+
selected: null,
|
|
1280
|
+
current: null
|
|
1281
|
+
});
|
|
1282
|
+
function createLinkEditing(options) {
|
|
1283
|
+
const { editor } = options;
|
|
1284
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1285
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1286
|
+
let state = LOOMA_LINK_EDITING_IDLE;
|
|
1287
|
+
let searchSequence = 0;
|
|
1288
|
+
let resolveSequence = 0;
|
|
1289
|
+
let currentHref = null;
|
|
1290
|
+
let destroyed = false;
|
|
1291
|
+
const set = (patch) => {
|
|
1292
|
+
const previous = state;
|
|
1293
|
+
state = {
|
|
1294
|
+
...state,
|
|
1295
|
+
...patch
|
|
1296
|
+
};
|
|
1297
|
+
if (state.query !== previous.query || state.mode !== previous.mode || state.surface !== previous.surface) search();
|
|
1298
|
+
listeners.forEach((listener) => listener());
|
|
1299
|
+
};
|
|
1300
|
+
const search = async () => {
|
|
1301
|
+
const sequence = ++searchSequence;
|
|
1302
|
+
const { query, mode, surface, selected } = state;
|
|
1303
|
+
state = {
|
|
1304
|
+
...state,
|
|
1305
|
+
results: [],
|
|
1306
|
+
searching: false
|
|
1307
|
+
};
|
|
1308
|
+
const find = options.search();
|
|
1309
|
+
if (!find || mode !== "target" || surface === "none" || query.trim().length < 2 || selected && query === selected.label) return;
|
|
1310
|
+
state = {
|
|
1311
|
+
...state,
|
|
1312
|
+
searching: true
|
|
1313
|
+
};
|
|
1314
|
+
try {
|
|
1315
|
+
const results = await find(query.trim());
|
|
1316
|
+
if (sequence === searchSequence && !destroyed) set({ results: results.filter((target) => validLinkHref(target.href)) });
|
|
1317
|
+
} catch {
|
|
1318
|
+
if (sequence === searchSequence && !destroyed) set({ error: "Search is unavailable. Try again." });
|
|
1319
|
+
} finally {
|
|
1320
|
+
if (sequence === searchSequence && !destroyed) set({ searching: false });
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
const resolveCurrent = async (href) => {
|
|
1324
|
+
const sequence = ++resolveSequence;
|
|
1325
|
+
if (state.current) set({ current: null });
|
|
1326
|
+
const resolve = options.resolve();
|
|
1327
|
+
if (!href || !resolve) return;
|
|
1328
|
+
if (cache.has(href)) {
|
|
1329
|
+
set({ current: cache.get(href) ?? null });
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
const target = await resolve(href);
|
|
1334
|
+
cache.set(href, target);
|
|
1335
|
+
if (sequence === resolveSequence && !destroyed) set({ current: target });
|
|
1336
|
+
} catch {}
|
|
1337
|
+
};
|
|
1338
|
+
const onTransaction = () => {
|
|
1339
|
+
const href = editor.isActive("link") ? String(editor.getAttributes("link").href ?? "") : null;
|
|
1340
|
+
if (href !== currentHref) {
|
|
1341
|
+
currentHref = href;
|
|
1342
|
+
resolveCurrent(href);
|
|
1343
|
+
}
|
|
1344
|
+
if (state.surface === "context" && editor.state.selection.from !== state.range?.from) set({ surface: "none" });
|
|
1345
|
+
};
|
|
1346
|
+
editor.on("transaction", onTransaction);
|
|
1347
|
+
const apply = (reportError = false) => {
|
|
1348
|
+
const range = state.range;
|
|
1349
|
+
if (editor.isDestroyed || !range) return false;
|
|
1350
|
+
const fail = (error) => {
|
|
1351
|
+
if (reportError) set({ error });
|
|
1352
|
+
return false;
|
|
1353
|
+
};
|
|
1354
|
+
const href = state.mode === "target" ? validLinkHref(state.selected?.href ?? "") : validLinkHref(state.href);
|
|
1355
|
+
if (!href) return fail(state.mode === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.");
|
|
1356
|
+
if (state.mode === "url" && options.search() && !range.existing && !/^(https?:|mailto:|tel:)/i.test(href)) return fail("Enter a full website, email, or phone URL, or choose a destination.");
|
|
1357
|
+
const baseUrl = options.baseUrl();
|
|
1358
|
+
const attrs = {
|
|
1359
|
+
href: baseUrl ? siteRelativeHref(href, baseUrl) : href,
|
|
1360
|
+
target: state.newTab ? "_blank" : "_self",
|
|
1361
|
+
rel: state.newTab ? "noopener noreferrer" : null
|
|
1362
|
+
};
|
|
1363
|
+
const current = editor.state.selection;
|
|
1364
|
+
const chain = editor.chain().setTextSelection({
|
|
1365
|
+
from: range.from,
|
|
1366
|
+
to: range.to
|
|
1367
|
+
});
|
|
1368
|
+
if (state.textEditable) {
|
|
1369
|
+
const text = state.text.trim();
|
|
1370
|
+
if (!text) return fail("Enter link text.");
|
|
1371
|
+
chain.insertContent({
|
|
1372
|
+
type: "text",
|
|
1373
|
+
text,
|
|
1374
|
+
marks: [{
|
|
1375
|
+
type: "link",
|
|
1376
|
+
attrs
|
|
1377
|
+
}]
|
|
1378
|
+
}).setTextSelection(range.from).run();
|
|
1379
|
+
set({
|
|
1380
|
+
range: {
|
|
1381
|
+
from: range.from,
|
|
1382
|
+
to: range.from + text.length,
|
|
1383
|
+
existing: true
|
|
1384
|
+
},
|
|
1385
|
+
error: ""
|
|
1386
|
+
});
|
|
1387
|
+
} else {
|
|
1388
|
+
if (range.existing && range.from === range.to) chain.extendMarkRange("link");
|
|
1389
|
+
chain.setLink(attrs).setTextSelection({
|
|
1390
|
+
from: current.from,
|
|
1391
|
+
to: current.to
|
|
1392
|
+
}).run();
|
|
1393
|
+
set({
|
|
1394
|
+
range: {
|
|
1395
|
+
...range,
|
|
1396
|
+
existing: true
|
|
1397
|
+
},
|
|
1398
|
+
error: ""
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
return true;
|
|
1402
|
+
};
|
|
1403
|
+
return {
|
|
1404
|
+
get state() {
|
|
1405
|
+
return state;
|
|
1406
|
+
},
|
|
1407
|
+
subscribe(listener) {
|
|
1408
|
+
listeners.add(listener);
|
|
1409
|
+
return () => {
|
|
1410
|
+
listeners.delete(listener);
|
|
1411
|
+
};
|
|
1412
|
+
},
|
|
1413
|
+
open(from, to, surface) {
|
|
1414
|
+
const empty = from === to;
|
|
1415
|
+
const existing = editor.isActive("link");
|
|
1416
|
+
const attrs = existing ? editor.getAttributes("link") : {};
|
|
1417
|
+
const href = typeof attrs.href === "string" ? attrs.href : "";
|
|
1418
|
+
const selected = cache.get(href) ?? null;
|
|
1419
|
+
set({
|
|
1420
|
+
surface,
|
|
1421
|
+
range: {
|
|
1422
|
+
from,
|
|
1423
|
+
to,
|
|
1424
|
+
existing
|
|
1425
|
+
},
|
|
1426
|
+
textEditable: empty && !existing,
|
|
1427
|
+
href,
|
|
1428
|
+
newTab: attrs.target !== "_self",
|
|
1429
|
+
text: empty && !existing ? "" : editor.state.doc.textBetween(from, to),
|
|
1430
|
+
error: "",
|
|
1431
|
+
selected,
|
|
1432
|
+
mode: options.search() && (!existing || selected) ? "target" : "url",
|
|
1433
|
+
query: selected?.label ?? href
|
|
1434
|
+
});
|
|
1435
|
+
const resolve = options.resolve();
|
|
1436
|
+
if (existing && resolve && !cache.has(href)) resolve(href).then((target) => {
|
|
1437
|
+
cache.set(href, target);
|
|
1438
|
+
if (!destroyed && state.href === href && target) set({
|
|
1439
|
+
selected: target,
|
|
1440
|
+
mode: "target",
|
|
1441
|
+
query: target.label
|
|
1442
|
+
});
|
|
1443
|
+
}).catch(() => {});
|
|
1444
|
+
},
|
|
1445
|
+
setDestination(value) {
|
|
1446
|
+
if (options.search()) {
|
|
1447
|
+
const trimmed = value.trim();
|
|
1448
|
+
const url = /^(https?:\/\/|mailto:|tel:)/i.test(trimmed) || Boolean(state.range?.existing && /^(\/|\.\.?\/|#|\?)/.test(trimmed));
|
|
1449
|
+
set({
|
|
1450
|
+
query: value,
|
|
1451
|
+
mode: url ? "url" : "target",
|
|
1452
|
+
selected: null,
|
|
1453
|
+
href: value,
|
|
1454
|
+
error: ""
|
|
1455
|
+
});
|
|
1456
|
+
} else set({
|
|
1457
|
+
href: value,
|
|
1458
|
+
error: ""
|
|
1459
|
+
});
|
|
1460
|
+
apply();
|
|
1461
|
+
},
|
|
1462
|
+
clear() {
|
|
1463
|
+
set({
|
|
1464
|
+
query: "",
|
|
1465
|
+
href: "",
|
|
1466
|
+
mode: "target",
|
|
1467
|
+
selected: null,
|
|
1468
|
+
error: ""
|
|
1469
|
+
});
|
|
1470
|
+
},
|
|
1471
|
+
choose(target) {
|
|
1472
|
+
cache.set(target.href, target);
|
|
1473
|
+
set({
|
|
1474
|
+
selected: target,
|
|
1475
|
+
href: target.href,
|
|
1476
|
+
query: target.label,
|
|
1477
|
+
results: [],
|
|
1478
|
+
newTab: false,
|
|
1479
|
+
error: "",
|
|
1480
|
+
...state.textEditable && !state.text.trim() ? { text: target.label } : {}
|
|
1481
|
+
});
|
|
1482
|
+
apply();
|
|
1483
|
+
},
|
|
1484
|
+
setNewTab(newTab) {
|
|
1485
|
+
set({ newTab });
|
|
1486
|
+
apply();
|
|
1487
|
+
},
|
|
1488
|
+
setText(text) {
|
|
1489
|
+
set({
|
|
1490
|
+
text,
|
|
1491
|
+
error: ""
|
|
1492
|
+
});
|
|
1493
|
+
apply();
|
|
1494
|
+
},
|
|
1495
|
+
apply,
|
|
1496
|
+
close() {
|
|
1497
|
+
if (state.surface !== "none") set({ surface: "none" });
|
|
1498
|
+
},
|
|
1499
|
+
remove() {
|
|
1500
|
+
if (!editor.isActive("link")) return;
|
|
1501
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
1502
|
+
if (state.surface === "context") set({ surface: "none" });
|
|
1503
|
+
},
|
|
1504
|
+
destroy() {
|
|
1505
|
+
destroyed = true;
|
|
1506
|
+
listeners.clear();
|
|
1507
|
+
editor.off("transaction", onTransaction);
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
//#endregion
|
|
1152
1512
|
//#region src/editor/selection-surface.ts
|
|
1153
1513
|
var surfaceSequence = 0;
|
|
1154
1514
|
/**
|
|
@@ -2493,28 +2853,13 @@ var LoomaEditor = defineComponent({
|
|
|
2493
2853
|
const element = instance?.$el;
|
|
2494
2854
|
linkFormRoot.value = element instanceof HTMLElement ? element : null;
|
|
2495
2855
|
};
|
|
2496
|
-
|
|
2497
|
-
const
|
|
2498
|
-
const linkHref = ref("");
|
|
2499
|
-
const linkText = ref("");
|
|
2500
|
-
const linkTextEditable = ref(false);
|
|
2501
|
-
const linkNewTab = ref(true);
|
|
2502
|
-
const linkError = ref("");
|
|
2503
|
-
const linkMode = ref("url");
|
|
2504
|
-
const linkQuery = ref("");
|
|
2505
|
-
const linkResults = ref([]);
|
|
2506
|
-
const linkSearching = ref(false);
|
|
2507
|
-
const linkSelectedTarget = ref(null);
|
|
2508
|
-
const linkContextTarget = ref(null);
|
|
2509
|
-
const linkTargetCache = /* @__PURE__ */ new Map();
|
|
2510
|
-
let linkSearchSequence = 0;
|
|
2511
|
-
let linkResolveSequence = 0;
|
|
2856
|
+
let linkEditing;
|
|
2857
|
+
const linkState = shallowRef(LOOMA_LINK_EDITING_IDLE);
|
|
2512
2858
|
const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
2513
2859
|
const chipInput = ref(null);
|
|
2514
2860
|
const chipOpen = ref(false);
|
|
2515
2861
|
const chipLabel = ref("");
|
|
2516
2862
|
const chipColor = ref("neutral");
|
|
2517
|
-
let linkSelection = null;
|
|
2518
2863
|
const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
2519
2864
|
const tooltipFor = ref("");
|
|
2520
2865
|
const tooltipLabel = ref("");
|
|
@@ -2565,7 +2910,7 @@ var LoomaEditor = defineComponent({
|
|
|
2565
2910
|
let heldPressTimer;
|
|
2566
2911
|
let heldPress = null;
|
|
2567
2912
|
const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
|
|
2568
|
-
const formattingPickerOpen = () =>
|
|
2913
|
+
const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
|
|
2569
2914
|
const cancelHeldPress = () => {
|
|
2570
2915
|
clearTimeout(heldPressTimer);
|
|
2571
2916
|
heldPressTimer = void 0;
|
|
@@ -2754,7 +3099,7 @@ var LoomaEditor = defineComponent({
|
|
|
2754
3099
|
if (view.isDestroyed) return;
|
|
2755
3100
|
editorStateVersion.value += 1;
|
|
2756
3101
|
const instance = editor.value;
|
|
2757
|
-
if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") &&
|
|
3102
|
+
if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && linkState.value.surface !== "popover") {
|
|
2758
3103
|
linkPopup?.setAnchor(() => link.getBoundingClientRect());
|
|
2759
3104
|
linkPopup?.show();
|
|
2760
3105
|
}
|
|
@@ -2777,7 +3122,6 @@ var LoomaEditor = defineComponent({
|
|
|
2777
3122
|
if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
|
|
2778
3123
|
if (!formattingSelectionActive(instance)) formattingPopup?.hide();
|
|
2779
3124
|
}
|
|
2780
|
-
if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
|
|
2781
3125
|
},
|
|
2782
3126
|
onUpdate: ({ editor: instance }) => {
|
|
2783
3127
|
const value = instance.getJSON();
|
|
@@ -2789,6 +3133,16 @@ var LoomaEditor = defineComponent({
|
|
|
2789
3133
|
editorStateVersion.value += 1;
|
|
2790
3134
|
});
|
|
2791
3135
|
editor.value = session.editor;
|
|
3136
|
+
const engine = createLinkEditing({
|
|
3137
|
+
editor: session.editor,
|
|
3138
|
+
search: () => props.linkSearch,
|
|
3139
|
+
resolve: () => props.linkResolve,
|
|
3140
|
+
baseUrl: () => props.linkBaseUrl
|
|
3141
|
+
});
|
|
3142
|
+
engine.subscribe(() => {
|
|
3143
|
+
linkState.value = engine.state;
|
|
3144
|
+
});
|
|
3145
|
+
linkEditing = engine;
|
|
2792
3146
|
});
|
|
2793
3147
|
const attachDocument = (element) => {
|
|
2794
3148
|
if (element instanceof HTMLElement) session?.attach(element);
|
|
@@ -2796,56 +3150,9 @@ var LoomaEditor = defineComponent({
|
|
|
2796
3150
|
onBeforeUnmount(() => {
|
|
2797
3151
|
const element = session?.editor.options.element;
|
|
2798
3152
|
element?.parentNode?.replaceChild(element.cloneNode(true), element);
|
|
3153
|
+
linkEditing?.destroy();
|
|
2799
3154
|
session?.destroy();
|
|
2800
3155
|
});
|
|
2801
|
-
watch(() => {
|
|
2802
|
-
editorStateVersion.value;
|
|
2803
|
-
return editor.value?.isActive("link") ? editor.value.getAttributes("link").href : null;
|
|
2804
|
-
}, async (href) => {
|
|
2805
|
-
const sequence = ++linkResolveSequence;
|
|
2806
|
-
linkContextTarget.value = null;
|
|
2807
|
-
if (!href || !props.linkResolve) return;
|
|
2808
|
-
if (linkTargetCache.has(href)) {
|
|
2809
|
-
linkContextTarget.value = linkTargetCache.get(href) ?? null;
|
|
2810
|
-
return;
|
|
2811
|
-
}
|
|
2812
|
-
try {
|
|
2813
|
-
const target = await props.linkResolve(href);
|
|
2814
|
-
linkTargetCache.set(href, target);
|
|
2815
|
-
if (sequence === linkResolveSequence) linkContextTarget.value = target;
|
|
2816
|
-
} catch {}
|
|
2817
|
-
});
|
|
2818
|
-
watch([
|
|
2819
|
-
linkQuery,
|
|
2820
|
-
linkMode,
|
|
2821
|
-
linkOpen,
|
|
2822
|
-
linkContextEditing
|
|
2823
|
-
], async ([query, mode, open, contextEditing]) => {
|
|
2824
|
-
const sequence = ++linkSearchSequence;
|
|
2825
|
-
linkResults.value = [];
|
|
2826
|
-
linkSearching.value = false;
|
|
2827
|
-
if (!props.linkSearch || mode !== "target" || !open && !contextEditing || query.trim().length < 2 || linkSelectedTarget.value && query === linkSelectedTarget.value.label) return;
|
|
2828
|
-
linkSearching.value = true;
|
|
2829
|
-
try {
|
|
2830
|
-
const results = await props.linkSearch(query.trim());
|
|
2831
|
-
if (sequence === linkSearchSequence) linkResults.value = results.filter((target) => validLinkHref(target.href));
|
|
2832
|
-
} catch {
|
|
2833
|
-
if (sequence === linkSearchSequence) linkError.value = "Search is unavailable. Try again.";
|
|
2834
|
-
} finally {
|
|
2835
|
-
if (sequence === linkSearchSequence) linkSearching.value = false;
|
|
2836
|
-
}
|
|
2837
|
-
});
|
|
2838
|
-
const selectLinkTarget = (target) => {
|
|
2839
|
-
linkSelectedTarget.value = target;
|
|
2840
|
-
linkTargetCache.set(target.href, target);
|
|
2841
|
-
linkHref.value = target.href;
|
|
2842
|
-
linkQuery.value = target.label;
|
|
2843
|
-
linkResults.value = [];
|
|
2844
|
-
linkNewTab.value = false;
|
|
2845
|
-
linkError.value = "";
|
|
2846
|
-
if (linkTextEditable.value && !linkText.value.trim()) linkText.value = target.label;
|
|
2847
|
-
applyLink();
|
|
2848
|
-
};
|
|
2849
3156
|
const openLinkEditor = (context = false) => {
|
|
2850
3157
|
const instance = editor.value;
|
|
2851
3158
|
if (!instance || !props.editable) return;
|
|
@@ -2855,130 +3162,37 @@ var LoomaEditor = defineComponent({
|
|
|
2855
3162
|
linkPressedSelection = null;
|
|
2856
3163
|
const from = selection?.from ?? current.from;
|
|
2857
3164
|
const to = selection?.to ?? current.to;
|
|
2858
|
-
|
|
2859
|
-
const existing = instance.isActive("link");
|
|
2860
|
-
linkSelection = {
|
|
2861
|
-
from,
|
|
2862
|
-
to,
|
|
2863
|
-
existing
|
|
2864
|
-
};
|
|
2865
|
-
linkTextEditable.value = empty && !existing;
|
|
2866
|
-
const attrs = existing ? instance.getAttributes("link") : {};
|
|
2867
|
-
linkHref.value = typeof attrs.href === "string" ? attrs.href : "";
|
|
2868
|
-
linkNewTab.value = attrs.target !== "_self";
|
|
2869
|
-
linkText.value = empty && !existing ? "" : instance.state.doc.textBetween(from, to);
|
|
2870
|
-
linkError.value = "";
|
|
2871
|
-
linkSelectedTarget.value = linkTargetCache.get(linkHref.value) ?? null;
|
|
2872
|
-
linkMode.value = props.linkSearch && (!existing || linkSelectedTarget.value) ? "target" : "url";
|
|
2873
|
-
linkQuery.value = linkSelectedTarget.value?.label ?? linkHref.value;
|
|
2874
|
-
linkContextEditing.value = context;
|
|
2875
|
-
linkOpen.value = !context;
|
|
2876
|
-
if (existing && props.linkResolve && !linkTargetCache.has(linkHref.value)) {
|
|
2877
|
-
const href = linkHref.value;
|
|
2878
|
-
props.linkResolve(href).then((target) => {
|
|
2879
|
-
linkTargetCache.set(href, target);
|
|
2880
|
-
if (linkHref.value === href && target) {
|
|
2881
|
-
linkSelectedTarget.value = target;
|
|
2882
|
-
linkMode.value = "target";
|
|
2883
|
-
linkQuery.value = target.label;
|
|
2884
|
-
}
|
|
2885
|
-
}).catch(() => {});
|
|
2886
|
-
}
|
|
3165
|
+
linkEditing?.open(from, to, context ? "context" : "popover");
|
|
2887
3166
|
nextTick(() => {
|
|
2888
3167
|
if (context) (props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(instance) ? formattingPopup : linkPopup)?.setAnchor(() => posToDOMRect(instance.view, instance.state.selection.from, instance.state.selection.to));
|
|
2889
|
-
(
|
|
2890
|
-
});
|
|
2891
|
-
};
|
|
2892
|
-
const applyLink = (reportError = false) => {
|
|
2893
|
-
const instance = editor.value;
|
|
2894
|
-
const selection = linkSelection;
|
|
2895
|
-
if (!instance || !selection) return false;
|
|
2896
|
-
const href = linkMode.value === "target" ? validLinkHref(linkSelectedTarget.value?.href ?? "") : validLinkHref(linkHref.value);
|
|
2897
|
-
if (!href) {
|
|
2898
|
-
if (reportError) linkError.value = linkMode.value === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.";
|
|
2899
|
-
return false;
|
|
2900
|
-
}
|
|
2901
|
-
if (linkMode.value === "url" && props.linkSearch && !selection.existing && !/^(https?:|mailto:|tel:)/i.test(href)) {
|
|
2902
|
-
if (reportError) linkError.value = "Enter a full website, email, or phone URL, or choose a destination.";
|
|
2903
|
-
return false;
|
|
2904
|
-
}
|
|
2905
|
-
const attrs = {
|
|
2906
|
-
href: props.linkBaseUrl ? siteRelativeHref(href, props.linkBaseUrl) : href,
|
|
2907
|
-
target: linkNewTab.value ? "_blank" : "_self",
|
|
2908
|
-
rel: linkNewTab.value ? "noopener noreferrer" : null
|
|
2909
|
-
};
|
|
2910
|
-
const current = instance.state.selection;
|
|
2911
|
-
const chain = instance.chain().setTextSelection({
|
|
2912
|
-
from: selection.from,
|
|
2913
|
-
to: selection.to
|
|
3168
|
+
(linkState.value.mode === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
|
|
2914
3169
|
});
|
|
2915
|
-
if (linkTextEditable.value) {
|
|
2916
|
-
const text = linkText.value.trim();
|
|
2917
|
-
if (!text) {
|
|
2918
|
-
if (reportError) linkError.value = "Enter link text.";
|
|
2919
|
-
return false;
|
|
2920
|
-
}
|
|
2921
|
-
linkSelection = {
|
|
2922
|
-
from: selection.from,
|
|
2923
|
-
to: selection.from + text.length,
|
|
2924
|
-
existing: true
|
|
2925
|
-
};
|
|
2926
|
-
chain.insertContent({
|
|
2927
|
-
type: "text",
|
|
2928
|
-
text,
|
|
2929
|
-
marks: [{
|
|
2930
|
-
type: "link",
|
|
2931
|
-
attrs
|
|
2932
|
-
}]
|
|
2933
|
-
}).setTextSelection(selection.from).run();
|
|
2934
|
-
} else {
|
|
2935
|
-
if (selection.existing && selection.from === selection.to) chain.extendMarkRange("link");
|
|
2936
|
-
chain.setLink(attrs).setTextSelection({
|
|
2937
|
-
from: current.from,
|
|
2938
|
-
to: current.to
|
|
2939
|
-
}).run();
|
|
2940
|
-
selection.existing = true;
|
|
2941
|
-
}
|
|
2942
|
-
linkError.value = "";
|
|
2943
|
-
return true;
|
|
2944
|
-
};
|
|
2945
|
-
const removeCurrentLink = () => {
|
|
2946
|
-
const instance = editor.value;
|
|
2947
|
-
if (!instance?.isActive("link")) return;
|
|
2948
|
-
instance.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
2949
|
-
linkContextEditing.value = false;
|
|
2950
3170
|
};
|
|
2951
3171
|
const dismissLink = (restoreFocus = true) => {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
if (restoreFocus && selection) editor.value?.chain().focus().setTextSelection(selection.to).run();
|
|
3172
|
+
linkEditing?.close();
|
|
3173
|
+
const range = linkState.value.range;
|
|
3174
|
+
if (restoreFocus && range) editor.value?.chain().focus().setTextSelection(range.to).run();
|
|
2956
3175
|
dismissedFormattingSelection = editor.value?.state.selection ?? null;
|
|
2957
3176
|
heldFormattingSelection = null;
|
|
2958
3177
|
linkPopup?.hide();
|
|
2959
3178
|
formattingPopup?.hide();
|
|
2960
3179
|
};
|
|
2961
|
-
const linkStatus = () => {
|
|
2962
|
-
if (linkSearching.value) return "Searching…";
|
|
2963
|
-
if (linkSelectedTarget.value) return "";
|
|
2964
|
-
if (linkQuery.value.trim().length < 2) return "Type at least two letters to search.";
|
|
2965
|
-
return linkResults.value.length || linkError.value ? "" : "No results found.";
|
|
2966
|
-
};
|
|
2967
3180
|
const renderLinkForm = () => {
|
|
2968
|
-
const
|
|
3181
|
+
const state = linkState.value;
|
|
3182
|
+
const shown = state.selected ? [state.selected] : state.results;
|
|
2969
3183
|
return h(EditorLinkForm, {
|
|
2970
3184
|
ref: setLinkFormRoot,
|
|
2971
3185
|
class: "looma-editor__link-form",
|
|
2972
|
-
existing: Boolean(
|
|
3186
|
+
existing: Boolean(state.range?.existing),
|
|
2973
3187
|
searchable: Boolean(props.linkSearch),
|
|
2974
|
-
mode:
|
|
2975
|
-
value: props.linkSearch ?
|
|
3188
|
+
mode: state.mode,
|
|
3189
|
+
value: props.linkSearch ? state.query : state.href,
|
|
2976
3190
|
placeholder: props.linkSearch ? `Search ${props.linkTargetLabel.toLowerCase()}s or paste a URL…` : "https://example.com",
|
|
2977
|
-
newTab:
|
|
2978
|
-
textEditable:
|
|
2979
|
-
text:
|
|
2980
|
-
error:
|
|
2981
|
-
status:
|
|
3191
|
+
newTab: state.newTab,
|
|
3192
|
+
textEditable: state.textEditable,
|
|
3193
|
+
text: state.text,
|
|
3194
|
+
error: state.error,
|
|
3195
|
+
status: linkFormStatus(state),
|
|
2982
3196
|
results: shown.map(({ id, label, detail }) => detail ? {
|
|
2983
3197
|
id,
|
|
2984
3198
|
label,
|
|
@@ -2987,46 +3201,23 @@ var LoomaEditor = defineComponent({
|
|
|
2987
3201
|
id,
|
|
2988
3202
|
label
|
|
2989
3203
|
}),
|
|
2990
|
-
selected:
|
|
2991
|
-
onDestination: (event) =>
|
|
2992
|
-
|
|
2993
|
-
if (props.linkSearch) {
|
|
2994
|
-
linkQuery.value = value;
|
|
2995
|
-
linkMode.value = /^(https?:\/\/|mailto:|tel:)/i.test(value.trim()) || linkSelection?.existing && /^(\/|\.\.?\/|#|\?)/.test(value.trim()) ? "url" : "target";
|
|
2996
|
-
linkSelectedTarget.value = null;
|
|
2997
|
-
}
|
|
2998
|
-
linkHref.value = value;
|
|
2999
|
-
linkError.value = "";
|
|
3000
|
-
applyLink();
|
|
3001
|
-
},
|
|
3002
|
-
onClear: () => {
|
|
3003
|
-
linkQuery.value = "";
|
|
3004
|
-
linkHref.value = "";
|
|
3005
|
-
linkMode.value = "target";
|
|
3006
|
-
linkSelectedTarget.value = null;
|
|
3007
|
-
linkError.value = "";
|
|
3008
|
-
},
|
|
3204
|
+
selected: state.selected?.id ?? "",
|
|
3205
|
+
onDestination: (event) => linkEditing?.setDestination(event.detail.value),
|
|
3206
|
+
onClear: () => linkEditing?.clear(),
|
|
3009
3207
|
onChoose: (event) => {
|
|
3010
3208
|
const target = shown.find(({ id }) => id === event.detail.id);
|
|
3011
|
-
if (target)
|
|
3012
|
-
},
|
|
3013
|
-
onNewTab: (event) => {
|
|
3014
|
-
linkNewTab.value = event.detail.newTab;
|
|
3015
|
-
applyLink();
|
|
3016
|
-
},
|
|
3017
|
-
onText: (event) => {
|
|
3018
|
-
linkText.value = event.detail.text;
|
|
3019
|
-
linkError.value = "";
|
|
3020
|
-
applyLink();
|
|
3209
|
+
if (target) linkEditing?.choose(target);
|
|
3021
3210
|
},
|
|
3211
|
+
onNewTab: (event) => linkEditing?.setNewTab(event.detail.newTab),
|
|
3212
|
+
onText: (event) => linkEditing?.setText(event.detail.text),
|
|
3022
3213
|
onSave: () => {
|
|
3023
|
-
if (
|
|
3214
|
+
if (linkEditing?.apply(true)) dismissLink();
|
|
3024
3215
|
},
|
|
3025
3216
|
onDismiss: () => dismissLink()
|
|
3026
3217
|
});
|
|
3027
3218
|
};
|
|
3028
3219
|
const renderLinkContextCard = (instance) => {
|
|
3029
|
-
if (
|
|
3220
|
+
if (linkState.value.surface === "context") return h(Card, {
|
|
3030
3221
|
variant: "elevated",
|
|
3031
3222
|
padding: "sm",
|
|
3032
3223
|
size: "sm",
|
|
@@ -3034,7 +3225,7 @@ var LoomaEditor = defineComponent({
|
|
|
3034
3225
|
}, () => [renderLinkForm()]);
|
|
3035
3226
|
const rawHref = instance.getAttributes("link").href;
|
|
3036
3227
|
const href = typeof rawHref === "string" ? rawHref : "";
|
|
3037
|
-
const target =
|
|
3228
|
+
const target = linkState.value.current;
|
|
3038
3229
|
return h(EditorLinkCard, {
|
|
3039
3230
|
class: "looma-editor__link-context",
|
|
3040
3231
|
href,
|
|
@@ -3043,35 +3234,9 @@ var LoomaEditor = defineComponent({
|
|
|
3043
3234
|
detail: target?.detail ?? "",
|
|
3044
3235
|
targetLabel: props.linkTargetLabel,
|
|
3045
3236
|
onEdit: () => openLinkEditor(true),
|
|
3046
|
-
onRemove:
|
|
3237
|
+
onRemove: () => linkEditing?.remove()
|
|
3047
3238
|
});
|
|
3048
3239
|
};
|
|
3049
|
-
const runBlockAction = (value) => {
|
|
3050
|
-
const instance = editor.value;
|
|
3051
|
-
const head = instance?.state.selection.$head;
|
|
3052
|
-
if (!instance || !head || head.depth === 0 || !props.editable) return;
|
|
3053
|
-
const { state } = instance;
|
|
3054
|
-
const position = head.before(1);
|
|
3055
|
-
const block = state.doc.nodeAt(position);
|
|
3056
|
-
if (!block) return;
|
|
3057
|
-
const end = position + block.nodeSize;
|
|
3058
|
-
const transaction = state.tr;
|
|
3059
|
-
let focusAt = end + 1;
|
|
3060
|
-
if (value === "insert-below") transaction.insert(end, state.schema.nodes.paragraph.create());
|
|
3061
|
-
else if (value === "duplicate") transaction.insert(end, block);
|
|
3062
|
-
else if (value === "delete") {
|
|
3063
|
-
if (state.doc.childCount === 1) {
|
|
3064
|
-
transaction.replaceWith(position, end, state.schema.nodes.paragraph.create());
|
|
3065
|
-
focusAt = 1;
|
|
3066
|
-
} else {
|
|
3067
|
-
transaction.delete(position, end);
|
|
3068
|
-
focusAt = Math.min(position + 1, transaction.doc.content.size);
|
|
3069
|
-
}
|
|
3070
|
-
} else return;
|
|
3071
|
-
transaction.setSelection(TextSelection.near(transaction.doc.resolve(Math.min(focusAt, transaction.doc.content.size))));
|
|
3072
|
-
instance.view.dispatch(transaction);
|
|
3073
|
-
instance.commands.focus();
|
|
3074
|
-
};
|
|
3075
3240
|
watch([() => props.slashCommands, () => props.uploadImage], () => {
|
|
3076
3241
|
slashExtension.options.commands = resolveSlashCommands();
|
|
3077
3242
|
editor.value?.commands.dismissLoomaSlashMenu();
|
|
@@ -3088,11 +3253,11 @@ var LoomaEditor = defineComponent({
|
|
|
3088
3253
|
if (!editable) chipOpen.value = false;
|
|
3089
3254
|
});
|
|
3090
3255
|
watch([
|
|
3091
|
-
|
|
3256
|
+
() => linkState.value.surface === "popover",
|
|
3092
3257
|
tablePickerOpen,
|
|
3093
3258
|
formatOpen
|
|
3094
|
-
], ([
|
|
3095
|
-
if (
|
|
3259
|
+
], ([linking, table, format]) => {
|
|
3260
|
+
if (linking || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
|
|
3096
3261
|
heldFormattingSelection = null;
|
|
3097
3262
|
formattingPopup?.hide();
|
|
3098
3263
|
});
|
|
@@ -3337,7 +3502,7 @@ var LoomaEditor = defineComponent({
|
|
|
3337
3502
|
const onDocumentPointerDown = (event) => {
|
|
3338
3503
|
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
3339
3504
|
const inElement = (element) => Boolean(element && (path.includes(element) || event.target instanceof Node && element.contains(event.target)));
|
|
3340
|
-
if (
|
|
3505
|
+
if (linkState.value.surface !== "none" && !inElement(linkFormRoot.value)) dismissLink(false);
|
|
3341
3506
|
const inTableUi = inElement(tableToolbarShell.value) || inElement(tableOverlayShell.value) || inElement(tableMenuShell.value) || inElement(mobileToolbarShell.value);
|
|
3342
3507
|
tableInteractionActive = inTableUi;
|
|
3343
3508
|
if (inTableUi) setTimeout(() => {
|
|
@@ -3403,7 +3568,7 @@ var LoomaEditor = defineComponent({
|
|
|
3403
3568
|
editor: instance,
|
|
3404
3569
|
element,
|
|
3405
3570
|
pluginKey: "looma-text-formatting-menu",
|
|
3406
|
-
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) ||
|
|
3571
|
+
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),
|
|
3407
3572
|
boundary: () => props.toolbarMode === "contextual" ? root.value : null,
|
|
3408
3573
|
related: editorRoot,
|
|
3409
3574
|
onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
|
|
@@ -3415,7 +3580,7 @@ var LoomaEditor = defineComponent({
|
|
|
3415
3580
|
editor: instance,
|
|
3416
3581
|
element,
|
|
3417
3582
|
pluginKey: "looma-link-context-menu",
|
|
3418
|
-
shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") &&
|
|
3583
|
+
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)),
|
|
3419
3584
|
maxWidth: () => Math.min(480, window.innerWidth - 24),
|
|
3420
3585
|
related: editorRoot,
|
|
3421
3586
|
onDismiss: (reason) => reason === "escape" ? dismissFormatting() : linkPopup?.hide()
|
|
@@ -3661,13 +3826,8 @@ var LoomaEditor = defineComponent({
|
|
|
3661
3826
|
if (!instance) return;
|
|
3662
3827
|
const table = overlayTableElement ?? selectedTableElement(instance);
|
|
3663
3828
|
if (!table) return;
|
|
3664
|
-
|
|
3665
|
-
if (targetCell && selectedTableElement(instance) !== table) instance.chain().focus().setTextSelection(instance.view.posAtDOM(targetCell, 0) + 1).run();
|
|
3829
|
+
focusTableOverlayTarget(instance, table, detail);
|
|
3666
3830
|
if (detail.action === "open-cell-menu") {
|
|
3667
|
-
const selectedCell = selectedTableCellElement(instance);
|
|
3668
|
-
const cell = table ? resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) ?? selectedCell : selectedCell;
|
|
3669
|
-
if (cell && selectedCell !== cell && !cell.classList.contains("selectedCell")) instance.chain().focus().setTextSelection(instance.view.posAtDOM(cell, 0) + 1).run();
|
|
3670
|
-
else instance.commands.focus();
|
|
3671
3831
|
updateTableUi();
|
|
3672
3832
|
tableUi.menuScope = "cell";
|
|
3673
3833
|
tableUi.menuStyle = {
|
|
@@ -3833,7 +3993,7 @@ var LoomaEditor = defineComponent({
|
|
|
3833
3993
|
label: "Link",
|
|
3834
3994
|
size: "sm",
|
|
3835
3995
|
variant: "ghost",
|
|
3836
|
-
"aria-expanded":
|
|
3996
|
+
"aria-expanded": linkState.value.surface === "popover" ? "true" : "false",
|
|
3837
3997
|
"aria-pressed": instance.isActive("link") ? "true" : "false",
|
|
3838
3998
|
"data-active": instance.isActive("link") ? "true" : "false",
|
|
3839
3999
|
onPointerenter: () => showTool(linkTriggerId, "Link", false),
|
|
@@ -4017,7 +4177,7 @@ var LoomaEditor = defineComponent({
|
|
|
4017
4177
|
ref: formattingSurfaceRef,
|
|
4018
4178
|
class: "looma-editor__floating-shell looma-editor__formatting-shell",
|
|
4019
4179
|
hidden: true
|
|
4020
|
-
}, [props.toolbarMode === "contextual" ? h(Stack, { gap: "xs" }, () => [renderToolbar(instance, true), (
|
|
4180
|
+
}, [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,
|
|
4021
4181
|
instance && props.editable ? h("div", { key: "looma-link-host" }, [h("div", {
|
|
4022
4182
|
ref: linkSurfaceRef,
|
|
4023
4183
|
class: "looma-editor__floating-shell looma-editor__link-shell",
|
|
@@ -4055,7 +4215,7 @@ var LoomaEditor = defineComponent({
|
|
|
4055
4215
|
languages: codeLanguageOptions.value,
|
|
4056
4216
|
onLanguage: (event) => chooseCodeLanguage(event.detail.value)
|
|
4057
4217
|
})]) : null,
|
|
4058
|
-
instance && (props.editable || props.selectionActions.length && !instance.state.selection.empty || activeRangeActions(instance).length > 0) && mobile.value && (editorFocused.value ||
|
|
4218
|
+
instance && (props.editable || props.selectionActions.length && !instance.state.selection.empty || activeRangeActions(instance).length > 0) && mobile.value && (editorFocused.value || linkState.value.surface === "popover" || !props.editable) ? h("div", {
|
|
4059
4219
|
key: "looma-mobile-toolbar-shell",
|
|
4060
4220
|
ref: mobileToolbarRef,
|
|
4061
4221
|
class: "looma-editor__mobile-toolbar-shell",
|
|
@@ -4150,17 +4310,18 @@ var LoomaEditor = defineComponent({
|
|
|
4150
4310
|
h(Popover, {
|
|
4151
4311
|
class: "looma-editor__link-popover",
|
|
4152
4312
|
size: "sm",
|
|
4153
|
-
open:
|
|
4313
|
+
open: linkState.value.surface === "popover",
|
|
4154
4314
|
for: linkPopoverAnchorId.value,
|
|
4155
4315
|
placement: mobile.value ? "top-start" : "bottom-start",
|
|
4156
4316
|
onOpen: () => openLinkEditor(),
|
|
4157
4317
|
onClose: () => {
|
|
4158
|
-
|
|
4318
|
+
if (linkState.value.surface === "popover") linkEditing?.close();
|
|
4159
4319
|
linkPressedSelection = null;
|
|
4160
4320
|
const current = editor.value?.state.selection;
|
|
4161
|
-
|
|
4321
|
+
const range = linkState.value.range;
|
|
4322
|
+
if (current && !current.empty && range && current.from === range.from && current.to === range.to) editor.value?.commands.setTextSelection(current.to);
|
|
4162
4323
|
}
|
|
4163
|
-
}, () =>
|
|
4324
|
+
}, () => linkState.value.surface === "popover" ? renderLinkForm() : null),
|
|
4164
4325
|
props.editable && props.compactToolbar && !mobile.value ? h(Popover, {
|
|
4165
4326
|
class: "looma-editor__compact-formatting",
|
|
4166
4327
|
for: formatAnchorId,
|
|
@@ -4176,7 +4337,9 @@ var LoomaEditor = defineComponent({
|
|
|
4176
4337
|
h(EditorBlockActions, {
|
|
4177
4338
|
for: blockActionAnchorId,
|
|
4178
4339
|
placement: mobile.value ? "top-start" : "bottom-start",
|
|
4179
|
-
onAction: (event) =>
|
|
4340
|
+
onAction: (event) => {
|
|
4341
|
+
if (props.editable && editor.value) runBlockAction(editor.value, event.detail.action);
|
|
4342
|
+
}
|
|
4180
4343
|
}),
|
|
4181
4344
|
h(Popover, {
|
|
4182
4345
|
class: "looma-editor__table-picker-popover",
|