573 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|