Files
docmost/packages/editor-ext/src/lib/utils.ts
T
Philip Okugbe 7981ef462e feat(editor): audio and PDF nodes (#2064)
* use local resizable

* feat: aduio

* support audio imports

* feat: use confluence real file names

* cleanup

* error handling

* hide notice

* add audio

* fix pulse

* Fix import and export

* unify pulse

* hide in readonly mode

* keywords

* keyword

* translations

* better sort

* feat: PDF embed

* cleanup

* remove audio menu

* open active

* hide focus on readonly mode

* increase iframe default dimension
2026-03-28 17:33:29 +00:00

415 lines
10 KiB
TypeScript

import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
import { EditorState, Selection, Transaction } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
import { customAlphabet } from "nanoid";
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(
selection.$anchorCell.pos - start,
selection.$headCell.pos - start,
),
);
for (let i = 0, count = cells.length; i < count; i += 1) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode(
(node) => node.type.spec.tableRole && node.type.spec.tableRole === "table",
)(selection);
export const isCellSelection = (selection: any) =>
selection instanceof CellSelection;
export const isColumnSelected = (columnIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1,
})(selection);
}
return false;
};
export const isTableSelected = (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const getCellsInColumn =
(columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex)
? columnIndex
: Array.from([columnIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
}),
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[],
);
}
return null;
};
export const getCellsInRow =
(rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex)
? rowIndex
: Array.from([rowIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
}),
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[],
);
}
return null;
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
return null;
};
export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: (node: Node) => boolean,
) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
};
}
}
return null;
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) =>
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select =
(type: "row" | "column") => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === "row";
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom,
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom,
});
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
// @ts-ignore
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select("column");
export const selectRow = select("row");
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
// @ts-ignore
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const isColumnGripSelected = ({
editor,
view,
state,
from,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !["TD", "TH"].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn =
container &&
container.querySelector &&
container.querySelector("a.grip-column.selected");
return !!gripColumn;
};
export const isRowGripSelected = ({
editor,
view,
state,
from,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !["TD", "TH"].includes(container.tagName)) {
container = container.parentElement!;
}
const gripRow =
container &&
container.querySelector &&
container.querySelector("a.grip-row.selected");
return !!gripRow;
};
export function isTextSelected(editor: Editor) {
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor;
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
}
export function setAttributes(
editor: Editor,
getPos: (() => number) | boolean,
attrs: Record<string, any>,
) {
if (editor.isEditable && typeof getPos === "function") {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, attrs),
);
}
}
export function icon(name: string) {
return `<span class="ProseMirror-icon ProseMirror-icon-${name}"></span>`;
}
export function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
const sanitized = braintreeSanitizeUrl(url);
// Return empty string instead of "about:blank"
return sanitized === "about:blank" ? "" : sanitized;
}
export function isInternalFileUrl(url: string | undefined): boolean {
if (!url) return false;
const normalized = url.trim();
return normalized.startsWith("/api/files/") || normalized.startsWith("/files/");
}
const alphabet = "abcdefghijklmnopqrstuvwxyz";
export const generateNodeId = customAlphabet(alphabet, 12);
export function copyToClipboard(text: string): void {
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text).catch(() => {
execCommandCopy(text);
});
} else {
execCommandCopy(text);
}
}
export function execCommandCopy(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}