zotero-better-notes/src/utils/editor.ts

573 lines
15 KiB
TypeScript

import TreeModel = require("tree-model");
import { TextSelection } from "prosemirror-state";
import { getNoteTreeFlattened } from "./note";
import { getPref } from "./prefs";
import { openLinkCreator } from "./linkCreator";
import { getNoteLink } from "./link";
import { showHint } from "./hint";
export {
insert,
del,
move,
replace,
scroll,
scrollToSection,
getEditorInstance,
copyNoteLink,
moveHeading,
updateHeadingTextAtLine,
getEditorCore,
getRangeAtCursor,
getLineAtCursor,
getSectionAtCursor,
getPositionAtLine,
getPositionAtCursor,
getLineCount,
getURLAtCursor,
updateImageDimensionsAtCursor,
updateURLAtCursor,
getTextBetween,
getTextBetweenLines,
isImageAtCursor,
initEditorPlugins,
};
function insert(
editor: Zotero.EditorInstance,
content: string = "",
position: number | "end" | "start" | "cursor" = "cursor",
select?: boolean,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
if (position === "cursor") {
position = getPositionAtCursor(editor);
} else if (position === "end") {
position = core.view.state.doc.content.size;
} else if (position === "start") {
position = 0;
}
position = Math.max(0, Math.min(position, core.view.state.doc.content.size));
(core as any).insertHTML(position, content);
if (select) {
const slice = EditorAPI.getSliceFromHTML(core.view.state, content);
EditorAPI.refocusEditor(() => {
EditorAPI.setSelection(
(position as number) + slice.content.size,
position as number,
)(core.view.state, core.view.dispatch);
});
}
}
function del(editor: Zotero.EditorInstance, from: number, to: number) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
EditorAPI.deleteRange(from, to)(core.view.state, core.view.dispatch);
}
function move(
editor: Zotero.EditorInstance,
from: number,
to: number,
delta: number,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
EditorAPI.moveRange(from, to, delta)(core.view.state, core.view.dispatch);
}
function replace(
editor: Zotero.EditorInstance,
from: number,
to: number,
text: string | undefined,
nodeTypeName:
| "doc"
| "paragraph"
| "heading"
| "math_display"
| "codeBlock"
| "blockquote"
| "horizontalRule"
| "orderedList"
| "bulletList"
| "listItem"
| "table"
| "table_row"
| "table_cell"
| "table_header"
| "text"
| "hardBreak"
| "image"
| "citation"
| "highlight"
| "math_inline",
nodeAttrs: Record<string, any>,
markTypeName:
| "strong"
| "em"
| "underline"
| "strike"
| "subsup"
| "textColor"
| "backgroundColor"
| "link"
| "code",
markAttrs: Record<string, any>,
select?: boolean,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
const schema = core.view.state.schema;
EditorAPI.replaceRangeNode(
from,
to,
text,
schema.nodes[nodeTypeName],
JSON.stringify(nodeAttrs),
schema.marks[markTypeName],
JSON.stringify(markAttrs),
select,
)(core.view.state, core.view.dispatch);
}
function scroll(editor: Zotero.EditorInstance, lineIndex: number) {
const core = getEditorCore(editor);
const dom = getDOMAtLine(editor, lineIndex);
const offset = dom.offsetTop;
core.view.dom.parentElement?.scrollTo(0, offset);
}
async function scrollToSection(
editor: Zotero.EditorInstance,
sectionName: string,
) {
const item = editor._item;
const sectionTree = await getNoteTreeFlattened(item);
const sectionNode = sectionTree.find(
(node) => node.model.name.trim() === sectionName.trim(),
);
if (!sectionNode) return;
scroll(editor, sectionNode.model.lineIndex);
}
function getEditorInstance(noteId: number) {
const editor = Zotero.Notes._editorInstances.find(
(e) =>
e._item.id === noteId && !Components.utils.isDeadWrapper(e._iframeWindow),
);
return editor;
}
function getEditorCore(editor: Zotero.EditorInstance): EditorCore {
return (editor._iframeWindow as any).wrappedJSObject._currentEditorInstance
._editorCore;
}
function getEditorAPI(editor: Zotero.EditorInstance) {
return (editor._iframeWindow as any).wrappedJSObject
.BetterNotesEditorAPI as EditorAPI;
}
function getPositionAtCursor(editor: Zotero.EditorInstance) {
const selection = getEditorCore(editor).view.state.selection;
try {
return selection.$anchor.after(selection.$anchor.depth);
} catch (e) {
return -1;
}
}
function getRangeAtCursor(editor: Zotero.EditorInstance) {
const selection = getEditorCore(editor).view.state.selection;
return {
from: selection.from,
to: selection.to,
};
}
function getLineAtCursor(editor: Zotero.EditorInstance) {
const position = getPositionAtCursor(editor);
if (position < 0) {
return -1;
}
const lastPos = getEditorCore(editor).view.state.tr.doc.content.size;
let i = 0;
let currentPos = getPositionAtLine(editor, 0);
while (currentPos <= lastPos) {
if (position <= currentPos) {
break;
}
i += 1;
currentPos = getPositionAtLine(editor, i);
}
return i;
}
async function getSectionAtCursor(
editor: Zotero.EditorInstance,
): Promise<string | undefined> {
const lineIndex = getLineAtCursor(editor);
if (lineIndex < 0) return undefined;
const item = editor._item;
const sectionTree = await getNoteTreeFlattened(item);
let sectionNode;
for (let i = 0; i < sectionTree.length; i++) {
if (
// Is before cursor
sectionTree[i].model.lineIndex <= lineIndex &&
// Is last node, or next node is after cursor
(i === sectionTree.length - 1 ||
sectionTree[i + 1].model.lineIndex > lineIndex)
) {
sectionNode = sectionTree[i];
break;
}
}
return sectionNode?.model.name;
}
function getDOMAtLine(
editor: Zotero.EditorInstance,
lineIndex: number,
): HTMLElement {
const core = getEditorCore(editor);
const lineNodeDesc =
core.view.docView.children[
Math.max(0, Math.min(core.view.docView.children.length - 1, lineIndex))
];
return lineNodeDesc?.dom;
}
function getPositionAtLine(
editor: Zotero.EditorInstance,
lineIndex: number,
type: "start" | "end" = "end",
): number {
const core = getEditorCore(editor);
const lineNodeDesc =
core.view.docView.children[
Math.max(0, Math.min(core.view.docView.children.length - 1, lineIndex))
];
const linePos = lineNodeDesc ? core.view.posAtDOM(lineNodeDesc.dom, 0) : 0;
return Math.max(
0,
Math.min(
type === "end" ? linePos + lineNodeDesc.size - 1 : linePos - 1,
core.view.state.tr.doc.content.size,
),
);
}
function getLineCount(editor: Zotero.EditorInstance) {
return getEditorCore(editor).view.docView.children.length;
}
function getURLAtCursor(editor: Zotero.EditorInstance) {
const core = getEditorCore(editor);
return core.pluginState.link.getHref(core.view.state);
}
function updateURLAtCursor(
editor: Zotero.EditorInstance,
text: string | undefined,
url: string,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
const from = core.view.state.selection.from;
const to = core.view.state.selection.to;
const schema = core.view.state.schema;
if (!url) {
return;
}
EditorAPI.replaceRangeAtCursor(
text,
schema.marks.link,
JSON.stringify({ href: url }),
schema.marks.link,
)(core.view.state, core.view.dispatch);
EditorAPI.refocusEditor(() => {
core.view.dispatch(
core.view.state.tr.setSelection(
TextSelection.create(core.view.state.tr.doc, from, to),
),
);
});
}
function updateHeadingTextAtLine(
editor: Zotero.EditorInstance,
lineIndex: number,
text: string,
) {
const core = getEditorCore(editor);
const schema = core.view.state.schema;
const EditorAPI = getEditorAPI(editor);
const from = getPositionAtLine(editor, lineIndex, "start");
const to = getPositionAtLine(editor, lineIndex, "end");
const level = EditorAPI.getHeadingLevelInRange(from, to)(core.view.state);
EditorAPI.replaceRangeNode(
from,
to,
text,
schema.nodes.heading,
JSON.stringify({ level }),
)(core.view.state, core.view.dispatch);
EditorAPI.refocusEditor(() => {
core.view.dispatch(
core.view.state.tr.setSelection(
TextSelection.create(core.view.state.tr.doc, from, from + text.length),
),
);
});
}
function isImageAtCursor(editor: Zotero.EditorInstance) {
return (
// @ts-ignore
getEditorCore(editor).view.state.selection.node?.type?.name === "image"
);
}
function updateImageDimensionsAtCursor(
editor: Zotero.EditorInstance,
width: number,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
EditorAPI.updateImageDimensions(
// @ts-ignore
core.view.state.selection.node.attrs.nodeID,
width,
undefined,
core.view.state,
core.view.dispatch,
);
}
function moveLines(
editor: Zotero.EditorInstance,
fromIndex: number,
toIndex: number,
targetIndex: number,
) {
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
const from = getPositionAtLine(editor, fromIndex, "start");
const to = getPositionAtLine(editor, toIndex, "end");
const target = getPositionAtLine(editor, targetIndex, "start");
let delta = 0;
if (target < from) {
delta = target - from;
} else if (target > to) {
delta = target - to;
} else {
throw new Error("Invalid move");
}
EditorAPI.moveRange(from, to, delta)(core.view.state, core.view.dispatch);
EditorAPI.refocusEditor(() => {
core.view.dispatch(
core.view.state.tr.setSelection(
TextSelection.create(
core.view.state.tr.doc,
target,
target + to - from,
),
),
);
});
}
function moveHeading(
editor: Zotero.EditorInstance | undefined,
currentNode: TreeModel.Node<NoteNodeData>,
targetNode: TreeModel.Node<NoteNodeData>,
as: "child" | "before" | "after",
) {
if (!editor || targetNode.getPath().indexOf(currentNode) >= 0) {
return;
}
let targetIndex = 0;
let targetLevel = 1;
if (as === "child") {
targetIndex = targetNode.model.endIndex + 1;
targetLevel = targetNode.model.level === 6 ? 6 : targetNode.model.level + 1;
} else if (as === "before") {
targetIndex = targetNode.model.lineIndex;
targetLevel =
targetNode.model.level === 7
? targetNode.parent.model.level === 6
? 6
: targetNode.parent.model.level + 1
: targetNode.model.level;
} else if (as === "after") {
targetIndex = targetNode.model.endIndex + 1;
targetLevel =
targetNode.model.level === 7
? targetNode.parent.model.level === 6
? 6
: targetNode.parent.model.level + 1
: targetNode.model.level;
}
const fromIndex = currentNode.model.lineIndex;
const toIndex = currentNode.model.endIndex;
const levelChange = targetLevel - currentNode.model.level;
const core = getEditorCore(editor);
const EditorAPI = getEditorAPI(editor);
EditorAPI.updateHeadingsInRange(
getPositionAtLine(editor, fromIndex, "start"),
getPositionAtLine(editor, toIndex, "end"),
levelChange,
)(core.view.state, core.view.dispatch);
moveLines(editor, fromIndex, toIndex, targetIndex);
}
function getTextBetween(
editor: Zotero.EditorInstance,
from: number,
to: number,
) {
const core = getEditorCore(editor);
return core.view.state.doc.textBetween(from, to);
}
function getTextBetweenLines(
editor: Zotero.EditorInstance,
fromIndex: number,
toIndex: number,
) {
const core = getEditorCore(editor);
const from = getPositionAtLine(editor, fromIndex, "start");
const to = getPositionAtLine(editor, toIndex, "end");
return core.view.state.doc.textBetween(from, to);
}
async function copyNoteLink(
editor: Zotero.EditorInstance,
mode: "section" | "line",
) {
const currentLine = getLineAtCursor(editor);
const currentSection = (await getSectionAtCursor(editor)) || "";
let link =
getNoteLink(editor._item, {
sectionName: mode === "section" ? currentSection : undefined,
lineIndex: mode === "line" ? currentLine : undefined,
}) || "";
if (!link) {
showHint("No note link found");
return;
}
if (mode === "section") {
link += `#${currentSection}`;
}
new ztoolkit.Clipboard()
.addText(link, "text/plain")
.addText(
`<a href="${link}">${editor._item.getNoteTitle().trim() || link}</a>`,
"text/html",
)
.copy();
showHint(`Link ${link} copied`);
}
function initEditorPlugins(editor: Zotero.EditorInstance) {
const previewType = getPref("editor.noteLinkPreviewType") as string;
if (!["hover", "ctrl"].includes(previewType)) {
return;
}
const EditorAPI = getEditorAPI(editor);
safeCall(() =>
EditorAPI.initPlugins(
Components.utils.cloneInto(
{
linkPreview: {
setPreviewContent: (
link: string,
setContent: (content: string) => void,
) => {
const note = addon.api.convert.link2note(link);
if (!note) {
setContent(
`<p style="color: red;">Invalid note link: ${link}</p>`,
);
return;
}
addon.api.convert
.link2html(link, {
noteItem: note,
dryRun: true,
usePosition: true,
})
.then((content) => setContent(content));
},
openURL: (url: string) => {
Zotero.getActiveZoteroPane().loadURI(url);
},
previewType,
},
magicKey: {
insertTemplate: () => {
addon.hooks.onShowTemplatePicker("insert", {
noteId: editor._item.id,
lineIndex: getLineAtCursor(editor),
});
},
insertLink: (mode: "inbound" | "outbound") => {
openLinkCreator(editor._item, {
lineIndex: getLineAtCursor(editor),
mode,
});
},
copyLink: (mode: "section" | "line") => {
copyNoteLink(editor, mode);
},
openAttachment: () => {
editor._item.parentItem
?.getBestAttachment()
.then((attachment) => {
if (!attachment) {
return;
}
Zotero.getActiveZoteroPane().viewAttachment([attachment.id]);
Zotero.Notifier.trigger("open", "file", attachment.id);
});
},
canOpenAttachment: () => {
const parentItem = editor._item.parentItem;
if (!parentItem) {
return false;
}
return (
(editor._item.parentItem as Zotero.Item).numAttachments() > 0
);
},
enable: getPref("editor.useMagicKey") as boolean,
},
markdownPaste: {
enable: getPref("editor.useMarkdownPaste") as boolean,
},
},
editor._iframeWindow,
{ wrapReflectors: true, cloneFunctions: true },
),
),
);
}
function safeCall(callback: () => void) {
try {
callback();
} catch (e) {
ztoolkit.log(e as Error);
}
}