// 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"; 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, }; // @ts-ignore window.BetterNotesEditorAPI = BetterNotesEditorAPI;