From e44e07eefad03640b64563d599ac9bd1354f4e28 Mon Sep 17 00:00:00 2001 From: windingwind <33902321+windingwind@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:19:57 +0100 Subject: [PATCH] add: magic key command palette --- addon/chrome/content/preferences.xhtml | 10 + addon/locale/en-US/preferences.ftl | 4 + addon/locale/it-IT/preferences.ftl | 4 + addon/locale/ru-RU/preferences.ftl | 4 + addon/locale/tr-TR/preferences.ftl | 4 + addon/locale/zh-CN/preferences.ftl | 4 + addon/prefs.js | 14 +- package-lock.json | 14 +- src/elements/linkCreator/notePicker.ts | 23 + src/elements/linkCreator/outboundCreator.ts | 2 +- src/extras/editor/editorStrings.ts | 75 +++ src/extras/editor/linkPreview.ts | 32 +- src/extras/editor/magicKey.ts | 523 ++++++++++++++++++ .../{pasteMarkdown.ts => markdownPaste.ts} | 10 +- src/extras/editor/plugins.ts | 16 +- src/extras/editor/popup.ts | 7 +- src/extras/linkCreator.ts | 16 +- src/extras/relationWorker.ts | 20 +- src/extras/templatePicker.ts | 5 +- src/modules/editor/initalize.ts | 4 +- src/modules/editor/linkPreview.ts | 5 - src/modules/editor/plugins.ts | 5 + src/modules/editor/popup.ts | 6 +- src/utils/editor.ts | 67 ++- src/utils/linkCreator.ts | 11 +- src/utils/relation.ts | 1 + src/utils/templatePicker.ts | 6 +- 27 files changed, 803 insertions(+), 89 deletions(-) create mode 100644 src/extras/editor/editorStrings.ts create mode 100644 src/extras/editor/magicKey.ts rename src/extras/editor/{pasteMarkdown.ts => markdownPaste.ts} (94%) delete mode 100644 src/modules/editor/linkPreview.ts create mode 100644 src/modules/editor/plugins.ts diff --git a/addon/chrome/content/preferences.xhtml b/addon/chrome/content/preferences.xhtml index 5412d92..dad1903 100644 --- a/addon/chrome/content/preferences.xhtml +++ b/addon/chrome/content/preferences.xhtml @@ -59,6 +59,16 @@ + + diff --git a/addon/locale/en-US/preferences.ftl b/addon/locale/en-US/preferences.ftl index e263684..1b01f8f 100644 --- a/addon/locale/en-US/preferences.ftl +++ b/addon/locale/en-US/preferences.ftl @@ -20,6 +20,10 @@ editor-noteLinkPreview-ctrl = } editor-noteLinkPreview-disable = .label = Never +editor-useMagicKey = + .label = Use magic key "/" to show command palette +editor-useMarkdownPaste = + .label = Use enhanced markdown paste sync-title = Sync sync-period-label = Auto-sync period (seconds) diff --git a/addon/locale/it-IT/preferences.ftl b/addon/locale/it-IT/preferences.ftl index f7b674c..2eb9e74 100644 --- a/addon/locale/it-IT/preferences.ftl +++ b/addon/locale/it-IT/preferences.ftl @@ -20,6 +20,10 @@ editor-noteLinkPreview-ctrl = } editor-noteLinkPreview-disable = .label = Never +editor-useMagicKey = + .label = Usa il tasto magico "/" per mostrare il pannello dei comandi +editor-useMarkdownPaste = + .label = Usa l'incolla markdown avanzato sync-title = Sincronizzazione sync-period-label = Intervallo della sincronizzazione automatica (secondi) diff --git a/addon/locale/ru-RU/preferences.ftl b/addon/locale/ru-RU/preferences.ftl index bd9b315..0d25504 100644 --- a/addon/locale/ru-RU/preferences.ftl +++ b/addon/locale/ru-RU/preferences.ftl @@ -20,6 +20,10 @@ editor-noteLinkPreview-ctrl = } editor-noteLinkPreview-disable = .label = Never +editor-useMagicKey = + .label = Использовать магическую клавишу "/" для отображения панели команд +editor-useMarkdownPaste = + .label = Использовать расширенное вставление Markdown sync-title = Синк sync-period-label = Авто-синк период (сек) diff --git a/addon/locale/tr-TR/preferences.ftl b/addon/locale/tr-TR/preferences.ftl index 2ddc68f..3378507 100644 --- a/addon/locale/tr-TR/preferences.ftl +++ b/addon/locale/tr-TR/preferences.ftl @@ -20,6 +20,10 @@ editor-noteLinkPreview-ctrl = } editor-noteLinkPreview-disable = .label = Never +editor-useMagicKey = + .label = Komut panelini göstermek için sihirli tuş "/" kullan +editor-useMarkdownPaste = + .label = Gelişmiş markdown yapıştırma kullan sync-title = Eşitle sync-period-label = Otomatik Eşitleme Sıklığı (saniye) diff --git a/addon/locale/zh-CN/preferences.ftl b/addon/locale/zh-CN/preferences.ftl index 8c077c3..66e14a1 100644 --- a/addon/locale/zh-CN/preferences.ftl +++ b/addon/locale/zh-CN/preferences.ftl @@ -20,6 +20,10 @@ editor-noteLinkPreview-ctrl = } editor-noteLinkPreview-disable = .label = 从不 +editor-useMagicKey = + .label = 使用魔法键 "/" 显示命令面板 +editor-useMarkdownPaste = + .label = 使用增强的Markdown粘贴 sync-title = 同步 sync-period-label = 自动同步周期 (秒) diff --git a/addon/prefs.js b/addon/prefs.js index f8fdc5d..ea9f566 100644 --- a/addon/prefs.js +++ b/addon/prefs.js @@ -6,22 +6,12 @@ pref("__prefsPrefix__.autoAnnotation", false); pref("__prefsPrefix__.insertLinkPosition", "end"); -pref("__prefsPrefix__.embedLink", true); -pref("__prefsPrefix__.standaloneLink", false); -pref("__prefsPrefix__.keepLink", true); -pref("__prefsPrefix__.exportMD", true); -pref("__prefsPrefix__.setAutoSync", false); -pref("__prefsPrefix__.withYAMLHeader", false); -pref("__prefsPrefix__.autoMDFileName", false); -pref("__prefsPrefix__.exportDocx", false); -pref("__prefsPrefix__.exportPDF", false); -pref("__prefsPrefix__.exportFreeMind", false); -pref("__prefsPrefix__.exportNote", false); - pref("__prefsPrefix__.workspace.outline.expandLevel", 2); pref("__prefsPrefix__.workspace.outline.keepLinks", true); pref("__prefsPrefix__.editor.noteLinkPreviewType", "hover"); +pref("__prefsPrefix__.editor.useMagicKey", true); +pref("__prefsPrefix__.editor.useMarkdownPaste", true); pref("__prefsPrefix__.openNote.takeover", true); pref("__prefsPrefix__.openNote.defaultAsWindow", false); diff --git a/package-lock.json b/package-lock.json index 88b2ab6..d1ac98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10042,9 +10042,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.19.4.tgz", - "integrity": "sha512-RPmVXxUfOhyFdayHawjuZCxiROsm9L4FCUA6pWI+l7n2yCBsWy9VpdE1hpDHUS8Vad661YLY9AzqfjLhAKQ4iQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.23.0.tgz", + "integrity": "sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==", "dev": true, "dependencies": { "orderedmap": "^2.0.0" @@ -10062,12 +10062,12 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.8.0.tgz", - "integrity": "sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", + "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", "dev": true, "dependencies": { - "prosemirror-model": "^1.0.0" + "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { diff --git a/src/elements/linkCreator/notePicker.ts b/src/elements/linkCreator/notePicker.ts index 056d53d..6ccf496 100644 --- a/src/elements/linkCreator/notePicker.ts +++ b/src/elements/linkCreator/notePicker.ts @@ -34,6 +34,8 @@ export class NotePicker extends PluginCEBase { _prefObserverID!: symbol; + _cachedLibraryIDs: number[] = []; + get content() { return MozXULElement.parseXULToFragment(` @@ -394,6 +396,12 @@ export class NotePicker extends PluginCEBase { onItemSelected() { this.activeSelectionType = "library"; + const selectedIDs = this.itemsView.getSelectedItems(true) as number[]; + // Compare the selected IDs with the cached IDs + // Since the library selection change can be triggered multiple times or with no change + if (arraysEqual(this._cachedLibraryIDs, selectedIDs)) { + return; + } this.dispatchSelectionChange(); } @@ -483,3 +491,18 @@ export class NotePicker extends PluginCEBase { } } } + +function arraysEqual(arr1: number[], arr2: number[]): boolean { + if (arr1.length !== arr2.length) return false; + + const set1 = new Set(arr1); + const set2 = new Set(arr2); + + if (set1.size !== set2.size) return false; + + for (const item of set1) { + if (!set2.has(item)) return false; + } + + return true; +} diff --git a/src/elements/linkCreator/outboundCreator.ts b/src/elements/linkCreator/outboundCreator.ts index 36e6745..86f4004 100644 --- a/src/elements/linkCreator/outboundCreator.ts +++ b/src/elements/linkCreator/outboundCreator.ts @@ -83,7 +83,7 @@ export class OutboundCreator extends PluginCEBase { await this.notePicker.load(); this.notePicker.addEventListener("selectionchange", (event: any) => { - this.targetNotes = event.detail.selectedNotes; + this.targetNotes = this.notePicker.getSelectedNotes(); this.updatePickerTitle(this.targetNotes); this.updateNotePreview(); if (this.targetNotes) this.scrollToSection("outline"); diff --git a/src/extras/editor/editorStrings.ts b/src/extras/editor/editorStrings.ts new file mode 100644 index 0000000..a4e0b46 --- /dev/null +++ b/src/extras/editor/editorStrings.ts @@ -0,0 +1,75 @@ +import { insert } from "../../utils/editor"; + +export { formatMessage }; + +function formatMessage(message: string, locale: string) { + const stringObj = editorStrings[message as keyof typeof editorStrings]; + if (!stringObj) { + return message; + } + + return stringObj[locale as "en-US" | "zh-CN"] || message; +} + +const editorStrings = { + insertTemplate: { + "en-US": "Insert Template", + "zh-CN": "插入模板", + }, + outboundLink: { + "en-US": "Insert Outbound Link (Link to another note)", + "zh-CN": "插入出链 (链接到另一个笔记)", + }, + inboundLink: { + "en-US": "Insert Inbound Link in another note (Link to this note)", + "zh-CN": "插入入链到另一笔记 (链接到本笔记)", + }, + insertCitation: { + "en-US": "Insert Citation", + "zh-CN": "插入引用", + }, + heading1: { + "en-US": "Heading 1", + "zh-CN": "一级标题", + }, + heading2: { + "en-US": "Heading 2", + "zh-CN": "二级标题", + }, + heading3: { + "en-US": "Heading 3", + "zh-CN": "三级标题", + }, + paragraph: { + "en-US": "Paragraph", + "zh-CN": "段落", + }, + monospaced: { + "en-US": "Monospaced", + "zh-CN": "等宽", + }, + bulletList: { + "en-US": "Bullet List", + "zh-CN": "无序列表", + }, + orderedList: { + "en-US": "Ordered List", + "zh-CN": "有序列表", + }, + blockquote: { + "en-US": "Blockquote", + "zh-CN": "引用", + }, + mathBlock: { + "en-US": "Math Block", + "zh-CN": "数学", + }, + clearFormatting: { + "en-US": "Clear Format", + "zh-CN": "清除格式", + }, + table: { + "en-US": "Table", + "zh-CN": "表格", + }, +}; diff --git a/src/extras/editor/linkPreview.ts b/src/extras/editor/linkPreview.ts index cbf88e5..db87268 100644 --- a/src/extras/editor/linkPreview.ts +++ b/src/extras/editor/linkPreview.ts @@ -15,10 +15,10 @@ interface LinkPreviewOptions { openURL: (url: string) => void; - requireCtrl: boolean; + previewType: "hover" | "ctrl" | "disable"; } -class LinkPreviewState { +class PluginState { state: EditorState; options: LinkPreviewOptions; @@ -40,6 +40,10 @@ class LinkPreviewState { update(state: EditorState, prevState?: EditorState) { this.state = state; + if (this.options.previewType === "disable") { + return; + } + if ( prevState && prevState.doc.eq(state.doc) && @@ -58,6 +62,10 @@ class LinkPreviewState { } handleMouseMove = async (event: MouseEvent) => { + if (this.options.previewType === "disable") { + return; + } + const { target } = event; let isValid = false; @@ -82,9 +90,10 @@ class LinkPreviewState { }; handleKeydown = async (event: KeyboardEvent) => { - if (!this.options.requireCtrl) { + if (this.options.previewType !== "ctrl") { return; } + if (!this.hasHover || !this.currentLink) { return; } @@ -96,9 +105,10 @@ class LinkPreviewState { }; tryOpenPopupByHover() { - if (this.options.requireCtrl) { + if (this.options.previewType !== "hover") { return; } + const href = this.currentLink!; setTimeout(() => { if (this.currentLink === href) { @@ -204,7 +214,7 @@ function initLinkPreviewPlugin( key, state: { init(config, state) { - return new LinkPreviewState(state, options); + return new PluginState(state, options); }, apply: (tr, pluginState, oldState, newState) => { pluginState.update(newState, oldState); @@ -214,16 +224,16 @@ function initLinkPreviewPlugin( props: { handleDOMEvents: { mousemove: (view, event) => { - const pluginState = key.getState(view.state) as LinkPreviewState; + const pluginState = key.getState(view.state) as PluginState; pluginState.update(view.state); pluginState.handleMouseMove(event); }, keydown: (view, event) => { - const pluginState = key.getState(view.state) as LinkPreviewState; + const pluginState = key.getState(view.state) as PluginState; pluginState.handleKeydown(event); }, wheel: (view, event) => { - const pluginState = key.getState(view.state) as LinkPreviewState; + const pluginState = key.getState(view.state) as PluginState; pluginState.popup?.layoutPopup(pluginState); }, }, @@ -231,13 +241,11 @@ function initLinkPreviewPlugin( view: (editorView) => { return { update(view, prevState) { - const pluginState = key.getState(view.state) as LinkPreviewState; + const pluginState = key.getState(view.state) as PluginState; pluginState.update(view.state, prevState); }, destroy() { - const pluginState = key.getState( - editorView.state, - ) as LinkPreviewState; + const pluginState = key.getState(editorView.state) as PluginState; pluginState.destroy(); }, }; diff --git a/src/extras/editor/magicKey.ts b/src/extras/editor/magicKey.ts new file mode 100644 index 0000000..3f3c55d --- /dev/null +++ b/src/extras/editor/magicKey.ts @@ -0,0 +1,523 @@ +import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state"; + +import { Popup } from "./popup"; +import { formatMessage } from "./editorStrings"; + +export { initMagicKeyPlugin, MagicKeyOptions }; + +declare const _currentEditorInstance: { + _editorCore: EditorCore; +}; + +interface MagicKeyOptions { + insertTemplate?: () => void; + insertLink?: (type: "inbound" | "outbound") => void; + enable?: boolean; +} + +interface MagicCommand { + messageId?: string; + title?: string; + icon?: string; + command: (state: EditorState) => void | Transaction; +} + +class PluginState { + state: EditorState; + + options: MagicKeyOptions; + + commands: MagicCommand[] = [ + { + messageId: "insertTemplate", + command: (state) => { + this.options.insertTemplate?.(); + }, + }, + { + messageId: "outboundLink", + command: (state) => { + this.options.insertLink?.("outbound"); + }, + }, + { + messageId: "inboundLink", + command: (state) => { + this.options.insertLink?.("inbound"); + }, + }, + { + messageId: "insertCitation", + command: (state) => { + getPlugin("citation")?.insertCitation(); + }, + }, + { + messageId: "table", + command: (state) => { + const input = prompt( + "Enter the number of rows and columns, separated by a comma (e.g., 3,3)", + ); + if (!input) { + return state.tr; + } + const splitter = input.includes("x") + ? "x" + : input.includes(",") + ? "," + : " "; + const [rows, cols] = input.split(splitter).map((n) => parseInt(n, 10)); + if (isNaN(rows) || isNaN(cols)) { + return state.tr; + } + const { tr, selection } = state; + const { $from, $to } = selection; + const { pos } = $from; + const table = state.schema.nodes.table.createAndFill( + {}, + Array.from( + { length: rows }, + () => + state.schema.nodes.table_row.createAndFill( + {}, + Array.from( + { length: cols }, + () => state.schema.nodes.table_cell.createAndFill()!, + ), + )!, + ), + )!; + tr.replaceWith(pos, pos, table); + _currentEditorInstance._editorCore.view.dispatch(tr); + }, + }, + { + messageId: "heading1", + command: (state) => { + getPlugin()?.heading1.run(); + }, + }, + { + messageId: "heading2", + command: (state) => { + getPlugin()?.heading2.run(); + }, + }, + { + messageId: "heading3", + command: (state) => { + getPlugin()?.heading3.run(); + }, + }, + { + messageId: "paragraph", + command: (state) => { + getPlugin()?.paragraph.run(); + }, + }, + { + messageId: "monospaced", + command: (state) => { + getPlugin()?.codeBlock.run(); + }, + }, + { + messageId: "bulletList", + command: (state) => { + getPlugin()?.bulletList.run(); + }, + }, + { + messageId: "orderedList", + command: (state) => { + getPlugin()?.orderedList.run(); + }, + }, + { + messageId: "blockquote", + command: (state) => { + getPlugin()?.blockquote.run(); + }, + }, + { + messageId: "mathBlock", + command: (state) => { + getPlugin()?.math_display.run(); + }, + }, + { + messageId: "clearFormatting", + command: (state) => { + getPlugin()?.clearFormatting.run(); + }, + }, + ]; + + popup: Popup | null = null; + + selectedCommandIndex = 0; + + get node() { + const node = + // @ts-ignore - private API + _currentEditorInstance._editorCore.view.domSelection().anchorNode; + if (node.nodeType === Node.TEXT_NODE) { + return node.parentElement; + } + return node; + } + + popupClass = "command-palette"; + + constructor(state: EditorState, options: MagicKeyOptions) { + this.state = state; + this.options = options; + + const locale = window.navigator.language || "en-US"; + for (const key in this.commands) { + const command = this.commands[key]; + if (command.messageId) { + command.title = formatMessage(command.messageId, locale); + } + } + + this.update(state); + } + + update(state: EditorState, prevState?: EditorState) { + this.state = state; + + if (!prevState || prevState.doc.eq(state.doc)) { + return; + } + // When `/` is pressed, we should open the command palette + const selectionText = state.doc.textBetween( + state.selection.from, + state.selection.to, + ); + if (!selectionText) { + const { $from } = this.state.selection; + const { parent } = $from; + // Don't open the popup if we are in the document root + if (parent.type.name === "doc") { + return; + } + const text = parent.textContent; + if (text.endsWith("/") && !text.endsWith("//")) { + this._openPopup(state); + } else { + this._closePopup(); + } + } + } + + destroy() { + this.popup?.remove(); + } + + handleKeydown = async (event: KeyboardEvent) => { + if (!this._hasPopup()) { + return; + } + + if (event.key === "Escape") { + this._closePopup(); + } + }; + + _openPopup(state: EditorState) { + if (this._hasPopup()) { + return; + } + this.popup = new Popup(document, this.popupClass, [ + document.createRange().createContextualFragment(` + +`), + ]); + + this.popup.layoutPopup(this); + + this.popup.container.scrollIntoView({ + block: "nearest", + }); + + // Focus the input + const input = this.popup.container.querySelector( + ".popup-input", + ) as HTMLInputElement; + input.focus(); + + // Handle input + input.addEventListener("input", (event) => { + const target = event.target as HTMLInputElement; + const value = target.value; + for (const [id, command] of Object.entries(this.commands)) { + const item = this.popup!.container.querySelector( + `.popup-item[data-command-id="${id}"]`, + ) as HTMLElement; + const matchedIndex = command + .title!.toLowerCase() + .indexOf(value.toLowerCase()); + if (matchedIndex >= 0) { + // Change the matched part to bold + const title = command.title!; + item.querySelector(".popup-item-title")!.innerHTML = + title.slice(0, matchedIndex) + + `${title.slice(matchedIndex, matchedIndex + value.length)}` + + title.slice(matchedIndex + value.length); + item.hidden = false; + } else { + item.hidden = true; + } + } + this._selectCommand(); + }); + + input.addEventListener("blur", () => { + if (__env__ === "development") { + return; + } + this._closePopup(); + }); + + input.addEventListener("keydown", (event) => { + if (event.key === "ArrowUp") { + this._selectCommand(this.selectedCommandIndex - 1); + event.preventDefault(); + } else if (event.key === "ArrowDown") { + this._selectCommand(this.selectedCommandIndex + 1); + event.preventDefault(); + } else if (event.key === "Enter") { + event.preventDefault(); + const command = this.commands[this.selectedCommandIndex]; + if (!command) { + this._closePopup(); + return; + } + this._executeCommand(this.selectedCommandIndex, state); + } else if (event.key === "Escape") { + this._closePopup(); + } else if (event.key === "z" && (event.ctrlKey || event.metaKey)) { + this._closePopup(); + this.removeInputSlash(state); + } + }); + + this.popup.container.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const target = event.target as HTMLElement; + // Find the command + const item = target.closest(".popup-item"); + if (!item) { + return; + } + const index = Array.from(item.parentElement!.children).indexOf(item); + + this._executeCommand(index, state); + }); + + this._selectCommand(0); + } + + _closePopup() { + if (!this._hasPopup()) { + return; + } + document + .querySelectorAll(`.${this.popupClass}`) + .forEach((el) => el.remove()); + this.popup = null; + window.BetterNotesEditorAPI.refocusEditor(); + } + + _hasPopup() { + return !!document.querySelector(`.${this.popupClass}`); + } + + _selectCommand(index?: number) { + if (typeof index === "undefined") { + index = this.selectedCommandIndex; + } + // Unselect the previous command + this.popup!.container.querySelectorAll(".popup-item.selected").forEach( + (el) => { + el.classList.remove("selected"); + }, + ); + + if (!this._hasPopup()) { + return; + } + const items = this.popup!.container.querySelectorAll( + ".popup-item", + ) as NodeListOf; + if (items[index]?.hidden) { + // Will find the first visible item + index = items.length; + } + if (index >= items.length) { + // Find the first visible item with :first-of-type + const item = this.popup!.container.querySelector( + ".popup-item:not([hidden])", + ) as HTMLElement; + index = parseInt(item?.dataset.commandId || "-1", 10); + } else if (index < 0) { + // Find the last visible item with :last-of-type + const visibleItems = this.popup!.container.querySelectorAll( + ".popup-item:not([hidden])", + ); + const item = visibleItems[visibleItems.length - 1] as HTMLElement; + index = parseInt(item?.dataset.commandId || "-1", 10); + } + + if (index < 0) { + this.selectedCommandIndex = -1; + return; + } + this.selectedCommandIndex = index; + items[index].classList.add("selected"); + // Record the scroll position of the top document + const scrollTop = document.querySelector(".editor-core")!.scrollTop; + items[index].scrollIntoView({ + block: "center", + }); + // Restore the scroll position + document.querySelector(".editor-core")!.scrollTop = scrollTop; + } + + _executeCommand(index: number, state: EditorState) { + const command = this.commands[index]; + if (!command) { + return; + } + // Remove the current input `/` + this.removeInputSlash(state); + + const newState = _currentEditorInstance._editorCore.view.state; + + // Apply the command + try { + const mightBeTr = command.command(newState); + if (mightBeTr) { + _currentEditorInstance._editorCore.view.dispatch(mightBeTr); + } + } catch (error) { + console.error("Error applying command", error); + } + + this._closePopup(); + } + + removeInputSlash(state: EditorState) { + const { $from } = state.selection; + const { pos } = $from; + const tr = state.tr.delete(pos - 1, pos); + _currentEditorInstance._editorCore.view.dispatch(tr); + } +} + +function initMagicKeyPlugin( + plugins: readonly Plugin[], + options: MagicKeyOptions, +) { + console.log("Init BN Magic Key Plugin"); + const key = new PluginKey("linkPreviewPlugin"); + return [ + ...plugins, + new Plugin({ + key, + state: { + init(config, state) { + return new PluginState(state, options); + }, + apply: (tr, pluginState, oldState, newState) => { + pluginState.update(newState, oldState); + return pluginState; + }, + }, + props: { + handleDOMEvents: { + keydown: (view, event) => { + const pluginState = key.getState(view.state) as PluginState; + pluginState.handleKeydown(event); + }, + }, + }, + view: (editorView) => { + return { + update(view, prevState) { + const pluginState = key.getState(view.state) as PluginState; + pluginState.update(view.state, prevState); + }, + destroy() { + const pluginState = key.getState(editorView.state) as PluginState; + pluginState.destroy(); + }, + }; + }, + }), + ]; +} + +function getPlugin(key = "menu") { + return _currentEditorInstance._editorCore.pluginState[key] as any; +} diff --git a/src/extras/editor/pasteMarkdown.ts b/src/extras/editor/markdownPaste.ts similarity index 94% rename from src/extras/editor/pasteMarkdown.ts rename to src/extras/editor/markdownPaste.ts index bd7e9ee..66040ea 100644 --- a/src/extras/editor/pasteMarkdown.ts +++ b/src/extras/editor/markdownPaste.ts @@ -1,15 +1,19 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { md2html } from "../../utils/convert"; -export { initPasteMarkdownPlugin }; +export { initMarkdownPastePlugin, MarkdownPasteOptions }; declare const _currentEditorInstance: { _editorCore: EditorCore; }; -function initPasteMarkdownPlugin(plugins: readonly Plugin[]) { +interface MarkdownPasteOptions { + enable: boolean; +} + +function initMarkdownPastePlugin(plugins: readonly Plugin[]) { const core = _currentEditorInstance._editorCore; - console.log("Init BN Paste Markdown Plugin"); + console.log("Init BN Markdown Paste Plugin"); const key = new PluginKey("pasteDropPlugin"); const oldPastePluginIndex = plugins.findIndex( (plugin) => plugin.props.handlePaste && plugin.props.handleDrop, diff --git a/src/extras/editor/plugins.ts b/src/extras/editor/plugins.ts index 51d5d72..9e86b69 100644 --- a/src/extras/editor/plugins.ts +++ b/src/extras/editor/plugins.ts @@ -1,5 +1,6 @@ import { initLinkPreviewPlugin, LinkPreviewOptions } from "./linkPreview"; -import { initPasteMarkdownPlugin } from "./pasteMarkdown"; +import { initMagicKeyPlugin, MagicKeyOptions } from "./magicKey"; +import { initMarkdownPastePlugin, MarkdownPasteOptions } from "./markdownPaste"; export { initPlugins }; @@ -7,11 +8,18 @@ declare const _currentEditorInstance: { _editorCore: EditorCore; }; -function initPlugins(options: LinkPreviewOptions) { +function initPlugins(options: { + linkPreview: LinkPreviewOptions; + magicKey: MagicKeyOptions; + markdownPaste: MarkdownPasteOptions; +}) { const core = _currentEditorInstance._editorCore; let plugins = core.view.state.plugins; - plugins = initLinkPreviewPlugin(plugins, options); - plugins = initPasteMarkdownPlugin(plugins); + if (options.linkPreview.previewType !== "disable") + plugins = initLinkPreviewPlugin(plugins, options.linkPreview); + if (options.markdownPaste.enable) plugins = initMarkdownPastePlugin(plugins); + if (options.magicKey.enable) + plugins = initMagicKeyPlugin(plugins, options.magicKey); // Collect all plugins and reconfigure the state only once const newState = core.view.state.reconfigure({ plugins, diff --git a/src/extras/editor/popup.ts b/src/extras/editor/popup.ts index c172d19..e770362 100644 --- a/src/extras/editor/popup.ts +++ b/src/extras/editor/popup.ts @@ -5,6 +5,8 @@ class Popup { hasHover = false; + className: string; + get container() { return this._popup; } @@ -18,6 +20,7 @@ class Popup { className?: string, children: (HTMLElement | DocumentFragment)[] = [], ) { + this.className = className || ""; this._popup = doc.createElement("div"); this._popup.className = `popup-container ${className}`; this._popup.innerHTML = ` @@ -58,7 +61,7 @@ class Popup { // Bottom const otherPopupHeight = Array.from( popupParent.querySelectorAll( - ".popup-container:not(.link-preview) > .popup.popup-bottom", + `.popup-container:not(.${this.className}) > .popup.popup-bottom`, ), ).reduce((acc, el) => acc + (el as HTMLElement).offsetHeight, 0); top = @@ -72,7 +75,7 @@ class Popup { // Top const otherPopupHeight = Array.from( popupParent.querySelectorAll( - ".popup-container:not(.link-preview) > .popup.popup-top", + `.popup-container:not(.${this.className}) > .popup.popup-top`, ), ).reduce((acc, el) => acc + (el as HTMLElement).offsetHeight, 0); top = diff --git a/src/extras/linkCreator.ts b/src/extras/linkCreator.ts index 3141491..6d8b81e 100644 --- a/src/extras/linkCreator.ts +++ b/src/extras/linkCreator.ts @@ -15,6 +15,7 @@ let io: { targetNoteID?: number; content?: string; lineIndex?: number; + mode?: "inbound" | "outbound"; }; window.onload = async function () { @@ -50,9 +51,20 @@ function init() { io = window.arguments[0]; + if (!io.deferred) { + // @ts-ignore + io = io.wrappedJSObject; + } + tabbox = document.querySelector("#top-container")!; - tabbox.selectedIndex = - (getPref("windows.linkCreator.tabIndex") as number) || 0; + + if (io.mode) { + tabbox.selectedIndex = io.mode === "inbound" ? 0 : 1; + } else { + tabbox.selectedIndex = + (getPref("windows.linkCreator.tabIndex") as number) || 0; + } + tabbox.addEventListener("select", loadSelectedPanel); inboundCreator = document.querySelector( diff --git a/src/extras/relationWorker.ts b/src/extras/relationWorker.ts index e87adf5..fd2bc26 100644 --- a/src/extras/relationWorker.ts +++ b/src/extras/relationWorker.ts @@ -38,10 +38,12 @@ messageServer.start(); async function addLink(model: LinkModel) { await db.link.add(model); + log("addLink", model); } async function bulkAddLink(models: LinkModel[]) { await db.link.bulkAdd(models); + log("bulkAddLink", models); } async function rebuildLinkForNote( @@ -51,15 +53,17 @@ async function rebuildLinkForNote( ) { log("rebuildLinkForNote", fromLibID, fromKey, links); - const collection = db.link.where({ fromLibID, fromKey }); - const oldOutboundLinks = await collection.toArray(); - await collection.delete().then((deleteCount) => { - log("Deleted " + deleteCount + " objects"); - return bulkAddLink(links); + return db.transaction("rw", db.link, async () => { + const collection = db.link.where({ fromLibID, fromKey }); + const oldOutboundLinks = await collection.toArray(); + await collection.delete().then((deleteCount) => { + log("Deleted " + deleteCount + " objects"); + return bulkAddLink(links); + }); + return { + oldOutboundLinks, + }; }); - return { - oldOutboundLinks, - }; } async function getOutboundLinks(fromLibID: number, fromKey: string) { diff --git a/src/extras/templatePicker.ts b/src/extras/templatePicker.ts index f0afc3e..10b5d33 100644 --- a/src/extras/templatePicker.ts +++ b/src/extras/templatePicker.ts @@ -7,7 +7,10 @@ document.addEventListener("DOMContentLoaded", (ev) => { document.addEventListener("dialogaccept", () => accept()); -const args = window.arguments[0] as any; +let args = window.arguments[0] as any; +if (!args._initPromise) { + args = args.wrappedJSObject; +} const templateData = args.templates; const multiSelect = args.multiSelect; let tableHelper: VirtualizedTableHelper; diff --git a/src/modules/editor/initalize.ts b/src/modules/editor/initalize.ts index 677671a..88db6ae 100644 --- a/src/modules/editor/initalize.ts +++ b/src/modules/editor/initalize.ts @@ -1,6 +1,6 @@ import { initEditorImagePreviewer } from "./image"; import { injectEditorCSS, injectEditorScripts } from "./inject"; -import { initEditorLinkPreview } from "./linkPreview"; +import { initEditorPlugins } from "./plugins"; import { initEditorMenu } from "./menu"; import { initEditorPopup } from "./popup"; import { initEditorToolbar } from "./toolbar"; @@ -38,5 +38,5 @@ async function onEditorInstanceCreated(editor: Zotero.EditorInstance) { await initEditorToolbar(editor); initEditorPopup(editor); initEditorMenu(editor); - initEditorLinkPreview(editor); + initEditorPlugins(editor); } diff --git a/src/modules/editor/linkPreview.ts b/src/modules/editor/linkPreview.ts deleted file mode 100644 index 6b8aa48..0000000 --- a/src/modules/editor/linkPreview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { initLinkPreview } from "../../utils/editor"; - -export function initEditorLinkPreview(editor: Zotero.EditorInstance) { - initLinkPreview(editor); -} diff --git a/src/modules/editor/plugins.ts b/src/modules/editor/plugins.ts new file mode 100644 index 0000000..af0e437 --- /dev/null +++ b/src/modules/editor/plugins.ts @@ -0,0 +1,5 @@ +import { initEditorPlugins as initEditorPluginsIntl } from "../../utils/editor"; + +export function initEditorPlugins(editor: Zotero.EditorInstance) { + initEditorPluginsIntl(editor); +} diff --git a/src/modules/editor/popup.ts b/src/modules/editor/popup.ts index e4c1637..5e37743 100644 --- a/src/modules/editor/popup.ts +++ b/src/modules/editor/popup.ts @@ -19,14 +19,16 @@ export function initEditorPopup(editor: Zotero.EditorInstance) { ztoolkit.log(mut); if ( (mut.addedNodes.length && - (mut.addedNodes[0] as HTMLElement).querySelector(".link-popup")) || + mut.addedNodes[0]?.hasChildNodes() && + (mut.addedNodes[0] as HTMLElement)?.querySelector(".link-popup")) || (mut.attributeName === "href" && mut.target.parentElement?.classList.contains("link")) ) { updateEditorLinkPopup(editor); } else if ( mut.addedNodes.length && - (mut.addedNodes[0] as HTMLElement).querySelector(".image-popup") + mut.addedNodes[0]?.hasChildNodes() && + (mut.addedNodes[0] as HTMLElement)?.querySelector(".image-popup") ) { updateEditorImagePopup(editor); } diff --git a/src/utils/editor.ts b/src/utils/editor.ts index 1da2689..188d8ca 100644 --- a/src/utils/editor.ts +++ b/src/utils/editor.ts @@ -2,6 +2,7 @@ import TreeModel = require("tree-model"); import { TextSelection } from "prosemirror-state"; import { getNoteTreeFlattened } from "./note"; import { getPref } from "./prefs"; +import { openLinkCreator } from "./linkCreator"; export { insert, @@ -26,7 +27,7 @@ export { getTextBetween, getTextBetweenLines, isImageAtCursor, - initLinkPreview, + initEditorPlugins, }; function insert( @@ -446,7 +447,7 @@ function getTextBetweenLines( return core.view.state.doc.textBetween(from, to); } -function initLinkPreview(editor: Zotero.EditorInstance) { +function initEditorPlugins(editor: Zotero.EditorInstance) { const previewType = getPref("editor.noteLinkPreviewType") as string; if (!["hover", "ctrl"].includes(previewType)) { return; @@ -456,29 +457,49 @@ function initLinkPreview(editor: Zotero.EditorInstance) { EditorAPI.initPlugins( Components.utils.cloneInto( { - setPreviewContent: ( - link: string, - setContent: (content: string) => void, - ) => { - const note = addon.api.convert.link2note(link); - if (!note) { - setContent( - `

Invalid note link: ${link}

`, - ); - return; - } - addon.api.convert - .link2html(link, { - noteItem: note, - dryRun: true, - usePosition: true, - }) - .then((content) => setContent(content)); + linkPreview: { + setPreviewContent: ( + link: string, + setContent: (content: string) => void, + ) => { + const note = addon.api.convert.link2note(link); + if (!note) { + setContent( + `

Invalid note link: ${link}

`, + ); + return; + } + addon.api.convert + .link2html(link, { + noteItem: note, + dryRun: true, + usePosition: true, + }) + .then((content) => setContent(content)); + }, + openURL: (url: string) => { + Zotero.getActiveZoteroPane().loadURI(url); + }, + previewType, }, - openURL: (url: string) => { - Zotero.getActiveZoteroPane().loadURI(url); + magicKey: { + insertTemplate: () => { + addon.hooks.onShowTemplatePicker("insert", { + noteId: editor._item.id, + lineIndex: getLineAtCursor(editor), + }); + }, + insertLink: (mode: "inbound" | "outbound") => { + openLinkCreator(editor._item, { + lineIndex: getLineAtCursor(editor), + mode, + }); + }, + enable: getPref("editor.useMagicKey") as boolean, + }, + markdownPaste: { + enable: getPref("editor.useMarkdownPaste") as boolean, }, - requireCtrl: previewType === "ctrl", }, editor._iframeWindow, { wrapReflectors: true, cloneFunctions: true }, diff --git a/src/utils/linkCreator.ts b/src/utils/linkCreator.ts index da88108..268337a 100644 --- a/src/utils/linkCreator.ts +++ b/src/utils/linkCreator.ts @@ -8,7 +8,8 @@ export { openLinkCreator }; async function openLinkCreator( currentNote: Zotero.Item, options?: { - lineIndex: number; + mode?: "inbound" | "outbound"; + lineIndex?: number; }, ) { if (!currentNote.id) { @@ -25,9 +26,13 @@ async function openLinkCreator( ), currentNoteID: currentNote.id, currentLineIndex: options?.lineIndex, + mode: options?.mode, deferred: Zotero.Promise.defer(), } as any; - Zotero.getMainWindow().openDialog( + + Services.ww.openWindow( + // @ts-ignore + null, `chrome://${config.addonRef}/content/linkCreator.xhtml`, `${config.addonRef}-linkCreator`, "chrome,modal,centerscreen,resizable=yes", @@ -42,6 +47,4 @@ async function openLinkCreator( if (!targetNote || !content) return; await addLineToNote(targetNote, content, lineIndex); - - await addon.api.relation.updateNoteLinkRelation(targetNote.id); } diff --git a/src/utils/relation.ts b/src/utils/relation.ts index 66c8613..e5acb4d 100644 --- a/src/utils/relation.ts +++ b/src/utils/relation.ts @@ -43,6 +43,7 @@ export { }; async function updateNoteLinkRelation(noteID: number) { + ztoolkit.log("updateNoteLinkRelation", noteID); const note = Zotero.Items.get(noteID); const affectedNoteIDs = new Set([noteID]); const fromLibID = note.libraryID; diff --git a/src/utils/templatePicker.ts b/src/utils/templatePicker.ts index ab38f9d..2647cca 100644 --- a/src/utils/templatePicker.ts +++ b/src/utils/templatePicker.ts @@ -18,7 +18,11 @@ export async function openTemplatePicker( selected: [] as string[], _initPromise: Zotero.Promise.defer(), }; - Zotero.getMainWindow().openDialog( + // @ts-ignore + // args.wrappedJSObject = args; + Services.ww.openWindow( + // @ts-ignore + null, `chrome://${config.addonRef}/content/templatePicker.xhtml`, "_blank", "chrome,modal,centerscreen,resizable=yes",