zotero-better-notes/src/extras/editor/magicKey.ts

601 lines
16 KiB
TypeScript

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;
copyLink?: (mode: "section" | "line") => void;
openAttachment?: () => void;
canOpenAttachment?: () => boolean;
enable?: boolean;
}
interface MagicCommand {
messageId?: string;
title?: string;
icon?: string;
command: (state: EditorState) => void | Transaction;
enabled?: (state: EditorState) => boolean;
}
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: "openAttachment",
command: (state) => {
this.options.openAttachment?.();
},
enabled: (state) => {
return this.options.canOpenAttachment?.() || false;
},
},
{
messageId: "copySectionLink",
command: (state) => {
this.options.copyLink?.("section");
},
},
{
messageId: "copyLineLink",
command: (state) => {
this.options.copyLink?.("line");
},
},
{
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();
},
},
];
get commands() {
return this._commands.filter((command) => {
if (command.enabled) {
return command.enabled(this.state);
}
return true;
});
}
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(`
<style>
.${this.popupClass} > .popup {
max-width: 360px;
max-height: 360px;
overflow: hidden;
}
.${this.popupClass} > .popup input {
padding: 0 7px;
background: var(--material-background);
border-radius: 5px;
border: var(--material-border-quinary);
width: 100%;
outline: none;
height: 28px;
flex-shrink: 0;
}
.${this.popupClass} > .popup input:focus {
outline: none;
border-color: rgba(0, 0, 0, 0);
box-shadow: 0 0 0 var(--width-focus-border) var(--color-focus-search);
}
.${this.popupClass} .popup-content {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px;
}
.${this.popupClass} .popup-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden auto;
}
.${this.popupClass} .popup-item {
padding: 6px;
cursor: pointer;
border-radius: 5px;
}
.${this.popupClass} .popup-item:hover {
background-color: var(--fill-senary);
}
.${this.popupClass} .popup-item.selected {
background-color: var(--color-accent);
color: #fff;
}
</style>
<div class="popup-content">
<input type="text" class="popup-input" placeholder="Search commands" />
<div class="popup-list" tabindex="-1">
${Object.entries(this.commands)
.map(
([id, command]) => `
<div class="popup-item" data-command-id="${id}">
<div class="popup-item-icon">${command.icon || ""}</div>
<div class="popup-item-title">${command.title}</div>
</div>`,
)
.join("")}
</div>
</div>`),
]);
this.popup.layoutPopup(this);
// 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) +
`<b>${title.slice(matchedIndex, matchedIndex + value.length)}</b>` +
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, "up");
event.preventDefault();
} else if (event.key === "ArrowDown") {
this._selectCommand(this.selectedCommandIndex + 1, "down");
event.preventDefault();
} else if (event.key === "ArrowLeft") {
// Select the first command
this._selectCommand(this.commands.length, "up");
event.preventDefault();
} else if (event.key === "ArrowRight") {
// Select the last command
this._selectCommand(-1, "down");
event.preventDefault();
} else if (event.key === "Tab") {
// If has input, autocomplete the selected command to the first space
const command = this.commands[this.selectedCommandIndex];
if (!command) {
return;
}
if (!input.value) {
return;
}
const title = command.title!;
// Compute after the matched part
const matchedIndex = title
.toLowerCase()
.indexOf(input.value.toLowerCase());
const spaceIndex = title.indexOf(
" ",
matchedIndex + input.value.length,
);
if (spaceIndex >= 0) {
input.value = title.slice(0, spaceIndex);
} else {
input.value = title;
}
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") {
event.preventDefault();
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, direction: "up" | "down" = "down") {
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<HTMLElement>;
if (items[index]?.hidden) {
// Will find the next visible item in the specified direction
if (direction === "up") {
for (let i = index - 1; i >= 0; i--) {
if (!items[i].hidden) {
index = i;
break;
}
}
} else if (direction === "down") {
for (let i = index + 1; i < items.length; i++) {
if (!items[i].hidden) {
index = i;
break;
}
}
}
}
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;
}