385 lines
9.6 KiB
TypeScript
385 lines
9.6 KiB
TypeScript
// The prosemirror imports are only for type hint
|
|
import {
|
|
Node,
|
|
NodeType,
|
|
Mark,
|
|
MarkType,
|
|
ResolvedPos,
|
|
Attrs,
|
|
DOMParser,
|
|
Schema,
|
|
} from "prosemirror-model";
|
|
import { EditorState, TextSelection } from "prosemirror-state";
|
|
import { EditorView } from "prosemirror-view";
|
|
import { initPlugins } from "./editor/plugins";
|
|
|
|
declare const _currentEditorInstance: {
|
|
_editorCore: EditorCore;
|
|
};
|
|
|
|
function fromHTML(schema: Schema, html: string, slice?: boolean) {
|
|
const domNode = document.createElement("div");
|
|
domNode.innerHTML = html;
|
|
const fragment = document.createDocumentFragment();
|
|
while (domNode.firstChild) {
|
|
fragment.appendChild(domNode.firstChild);
|
|
}
|
|
if (slice) {
|
|
return DOMParser.fromSchema(schema).parseSlice(fragment);
|
|
} else {
|
|
return DOMParser.fromSchema(schema).parse(fragment);
|
|
}
|
|
}
|
|
|
|
function getSliceFromHTML(state: EditorState, html: string) {
|
|
return fromHTML(state.schema, html, true);
|
|
}
|
|
|
|
function getNodeFromHTML(state: EditorState, html: string) {
|
|
return fromHTML(state.schema, html);
|
|
}
|
|
|
|
function setSelection(anchor: number, head?: number | undefined) {
|
|
return (state: EditorState, dispatch?: EditorView["dispatch"]) => {
|
|
const { tr, selection } = state;
|
|
const _TextSelection =
|
|
selection.constructor as unknown as typeof TextSelection;
|
|
tr.setSelection(_TextSelection.create(tr.doc, anchor, head));
|
|
dispatch && dispatch(tr);
|
|
};
|
|
}
|
|
|
|
// Code from https://github.com/ueberdosis/tiptap/tree/main/packages/core/src/helpers
|
|
|
|
function objectIncludes(object1: any, object2: any) {
|
|
const keys = Object.keys(object2);
|
|
|
|
if (!keys.length) {
|
|
return true;
|
|
}
|
|
|
|
return !!keys.filter((key) => object2[key] === object1[key]).length;
|
|
}
|
|
|
|
function findMarkInSet(
|
|
marks: readonly Mark[],
|
|
type: MarkType,
|
|
attributes = {},
|
|
) {
|
|
return marks.find((item) => {
|
|
return item.type === type && objectIncludes(item.attrs, attributes);
|
|
});
|
|
}
|
|
|
|
function isMarkInSet(marks: readonly Mark[], type: MarkType, attributes = {}) {
|
|
return !!findMarkInSet(marks, type, attributes);
|
|
}
|
|
|
|
function getMarkRange($pos: ResolvedPos, type: MarkType, attributes = {}) {
|
|
if (!$pos || !type) {
|
|
return;
|
|
}
|
|
|
|
const start = $pos.parent.childAfter($pos.parentOffset);
|
|
|
|
if (!start.node) {
|
|
return;
|
|
}
|
|
|
|
const mark = findMarkInSet(start.node.marks, type, attributes);
|
|
|
|
if (!mark) {
|
|
return;
|
|
}
|
|
|
|
let startIndex = $pos.index();
|
|
let startPos = $pos.start() + start.offset;
|
|
let endIndex = startIndex + 1;
|
|
let endPos = startPos + start.node.nodeSize;
|
|
|
|
findMarkInSet(start.node.marks, type, attributes);
|
|
|
|
while (
|
|
startIndex > 0 &&
|
|
mark.isInSet($pos.parent.child(startIndex - 1).marks)
|
|
) {
|
|
startIndex -= 1;
|
|
startPos -= $pos.parent.child(startIndex).nodeSize;
|
|
}
|
|
|
|
while (
|
|
endIndex < $pos.parent.childCount &&
|
|
isMarkInSet($pos.parent.child(endIndex).marks, type, attributes)
|
|
) {
|
|
endPos += $pos.parent.child(endIndex).nodeSize;
|
|
endIndex += 1;
|
|
}
|
|
|
|
return {
|
|
from: startPos,
|
|
to: endPos,
|
|
};
|
|
}
|
|
|
|
function getMarkRangeAtCursor(state: EditorState, type: MarkType) {
|
|
const { selection } = state;
|
|
const { $from } = selection;
|
|
|
|
const start = $from.parent.childAfter($from.parentOffset);
|
|
if (start.node) {
|
|
const mark = start.node.marks.find((mark) => mark.type.name === type.name);
|
|
if (mark) {
|
|
return getMarkRange($from, type, mark.attrs);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function deleteRange(from: number, to: number) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr } = state;
|
|
console.log("Delete Node", from, to);
|
|
tr.delete(from, to);
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function deleteRangeAtCursor(searchType: MarkType) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const range = getMarkRangeAtCursor(state, searchType);
|
|
if (range) {
|
|
const from = range.from;
|
|
const to = range.to;
|
|
return deleteRange(from, to)(state, dispatch);
|
|
}
|
|
};
|
|
}
|
|
|
|
function replaceRange(
|
|
from: number,
|
|
to: number,
|
|
text: string | undefined,
|
|
type: MarkType,
|
|
attrs: Attrs | string,
|
|
) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr } = state;
|
|
if (typeof attrs === "string") {
|
|
attrs = JSON.parse(attrs) as Attrs;
|
|
}
|
|
|
|
const node = state.schema.text(text || state.doc.textBetween(from, to), [
|
|
type.create(attrs),
|
|
]);
|
|
console.log("Replace Node", from, to, node);
|
|
tr.replaceWith(from, to, node);
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function replaceRangeNode(
|
|
from: number,
|
|
to: number,
|
|
text: string | undefined,
|
|
nodeType: NodeType,
|
|
nodeAttrs: Attrs | string,
|
|
markType?: MarkType,
|
|
markAttrs?: Attrs | string,
|
|
select?: boolean,
|
|
) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr } = state;
|
|
if (typeof nodeAttrs === "string") {
|
|
nodeAttrs = JSON.parse(nodeAttrs) as Attrs;
|
|
}
|
|
if (typeof markAttrs === "string") {
|
|
markAttrs = JSON.parse(markAttrs) as Attrs;
|
|
}
|
|
|
|
const node = nodeType.create(
|
|
nodeAttrs,
|
|
state.schema.text(text || state.doc.textBetween(from, to)),
|
|
markType ? [markType.create(markAttrs)] : [],
|
|
);
|
|
console.log("Replace Node", from, to, node);
|
|
tr.replaceWith(from, to, node);
|
|
if (select) {
|
|
setSelection(from + node.nodeSize, from)(state);
|
|
}
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function replaceRangeAtCursor(
|
|
text: string | undefined,
|
|
type: MarkType,
|
|
attrs: Attrs | string,
|
|
searchType: MarkType,
|
|
) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const range = getMarkRangeAtCursor(state, searchType);
|
|
if (range) {
|
|
const from = range.from;
|
|
const to = range.to;
|
|
return replaceRange(from, to, text, type, attrs)(state, dispatch);
|
|
}
|
|
};
|
|
}
|
|
|
|
function moveRange(from: number, to: number, delta: number) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr, selection } = state;
|
|
const _TextSelection =
|
|
selection.constructor as unknown as typeof TextSelection;
|
|
const slice = state.doc.slice(from, to);
|
|
console.log("Move Node", from, to, delta, slice);
|
|
tr.delete(from, to);
|
|
tr.insert(from + delta, slice.content);
|
|
tr.setSelection(_TextSelection.create(tr.doc, from + delta));
|
|
tr.scrollIntoView();
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function updateMarkRangeAtCursor(type: MarkType, attrs: Attrs) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr, selection, doc } = state;
|
|
let { from, to } = selection;
|
|
const { $from, empty } = selection;
|
|
|
|
if (empty) {
|
|
const range = getMarkRangeAtCursor(state, type);
|
|
if (range) {
|
|
from = range.from;
|
|
to = range.to;
|
|
}
|
|
}
|
|
|
|
const hasMark = doc.rangeHasMark(from, to, type);
|
|
|
|
if (hasMark) {
|
|
tr.removeMark(from, to, type);
|
|
}
|
|
|
|
tr.addStoredMark(type.create(attrs));
|
|
|
|
if (to > from) {
|
|
tr.addMark(from, to, type.create(attrs));
|
|
}
|
|
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function removeMarkRangeAtCursor(type: MarkType) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
const { tr, selection } = state;
|
|
let { from, to } = selection;
|
|
const { $from, empty } = selection;
|
|
|
|
if (empty) {
|
|
const range = getMarkRangeAtCursor(state, type);
|
|
if (range) {
|
|
from = range.from;
|
|
to = range.to;
|
|
}
|
|
}
|
|
|
|
tr.ensureMarks([]);
|
|
if (to > from) {
|
|
tr.removeMark(from, to, type);
|
|
}
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function getHeadingLevelInRange(from: number, to: number) {
|
|
return (state: EditorState) => {
|
|
let level = -1;
|
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
if (node.type.name === "heading") {
|
|
level = node.attrs.level;
|
|
}
|
|
});
|
|
return level;
|
|
};
|
|
}
|
|
|
|
function updateHeadingsInRange(from: number, to: number, levelOffset: number) {
|
|
return (state: EditorState, dispatch: EditorView["dispatch"]) => {
|
|
let { tr } = state;
|
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
if (node.type.name === "heading") {
|
|
tr = tr.setNodeMarkup(pos, state.schema.nodes.heading, {
|
|
level: node.attrs.level + levelOffset,
|
|
});
|
|
}
|
|
});
|
|
dispatch(tr);
|
|
};
|
|
}
|
|
|
|
function refocusEditor(callback: (args: void) => void) {
|
|
const scrollTop = document.querySelector(".editor-core")!.scrollTop;
|
|
const input = document.createElement("input");
|
|
input.style.position = "absolute";
|
|
input.style.opacity = "0";
|
|
document.body.append(input);
|
|
input.focus();
|
|
input.offsetTop;
|
|
setTimeout(() => {
|
|
(document.querySelector(".primary-editor") as any).focus();
|
|
input.remove();
|
|
document.querySelector(".editor-core")!.scrollTop = scrollTop;
|
|
setTimeout(callback, 0);
|
|
}, 0);
|
|
}
|
|
|
|
function updateImageDimensions(
|
|
nodeID: string,
|
|
width: number,
|
|
height: number | undefined,
|
|
state: EditorState,
|
|
dispatch: EditorView["dispatch"],
|
|
) {
|
|
const { tr } = state;
|
|
state.doc.descendants((node: Node, pos: number) => {
|
|
if (node.type.name === "image" && node.attrs.nodeID === nodeID) {
|
|
// tr.step(new SetAttrsStep(pos, { ...node.attrs, width, height }));
|
|
// tr.setMeta("addToHistory", false);
|
|
// tr.setMeta("system", true);
|
|
tr.setNodeMarkup(
|
|
pos,
|
|
node.type,
|
|
{ ...node.attrs, width, height },
|
|
node.marks,
|
|
);
|
|
dispatch(tr);
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
export const BetterNotesEditorAPI = {
|
|
deleteRange,
|
|
deleteRangeAtCursor,
|
|
replaceRange,
|
|
replaceRangeNode,
|
|
replaceRangeAtCursor,
|
|
moveRange,
|
|
updateMarkRangeAtCursor,
|
|
removeMarkRangeAtCursor,
|
|
refocusEditor,
|
|
updateImageDimensions,
|
|
getHeadingLevelInRange,
|
|
updateHeadingsInRange,
|
|
getSliceFromHTML,
|
|
getNodeFromHTML,
|
|
setSelection,
|
|
initPlugins,
|
|
};
|
|
|
|
// @ts-ignore
|
|
window.BetterNotesEditorAPI = BetterNotesEditorAPI;
|