import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref"; import type { Client } from "../client.ts"; import type { EventPayLoad } from "./lua_widget.ts"; import { isLocalURL, resolveMarkdownLink, } from "@silverbulletmd/silverbullet/lib/resolve"; export function buildTranslateUrls(client: Client): (url: string) => string { return (url: string) => { if (isLocalURL(url)) { return resolveMarkdownLink(client.currentName(), decodeURI(url)); } return url; }; } export function moveCursorToWidgetStart( client: Client, widgetDom: HTMLElement, widgetText?: string, ) { const view = client.editorView; const pos = view.posAtDOM(widgetDom, 0); let anchor = pos; if (widgetText) { // The widget decoration may be placed at the end of the source range // (node.to). Search near posAtDOM for the actual text to find its start. const searchFrom = Math.max(0, pos - widgetText.length); const region = view.state.sliceDoc(searchFrom, pos + widgetText.length); const idx = region.lastIndexOf(widgetText); if (idx !== -1) { anchor = searchFrom + idx; } } view.dispatch({ selection: { anchor } }); client.focus(); } /** * Locate a widget's source text range in the document. Mirrors the search in * `moveCursorToWidgetStart`: `posAtDOM` gives an approximate position, then we * search nearby for the literal `widgetText` (e.g. the full `${…}` directive). * Returns null if it can't be found. */ export function findWidgetSourceRange( client: Client, widgetDom: HTMLElement, widgetText: string, ): { from: number; to: number } | null { const view = client.editorView; const pos = view.posAtDOM(widgetDom, 0); const searchFrom = Math.max(0, pos - widgetText.length); const region = view.state.sliceDoc(searchFrom, pos + widgetText.length); const idx = region.lastIndexOf(widgetText); if (idx === -1) { return null; } const from = searchFrom + idx; return { from, to: from + widgetText.length }; } export function attachWidgetEventHandlers( div: HTMLElement, client: Client, widgetText?: string, events?: Record void>, ) { if (!div.dataset.handlersAttached) { div.dataset.handlersAttached = "true"; div.addEventListener("mousedown", (e) => { if (e.altKey && widgetText) { // Move cursor there moveCursorToWidgetStart(client, div, widgetText); e.preventDefault(); } // CodeMirror overrides mousedown on parent elements to implement its own selection highlighting. // That's nice, but not for markdown widgets, so let's not propagate the event to CodeMirror here. e.stopPropagation(); }); div.addEventListener("mouseup", (e) => { // Same as above e.stopPropagation(); }); } // Override wiki links with local navigate (faster) div.querySelectorAll("a[data-ref]").forEach((el_) => { const el = el_ as HTMLElement; // Override default click behavior with a local navigate (faster). // Ctrl/meta-click navigates in a new window: we can't rely on the // browser's native "open in new tab" for the anchor's href, because // inside the desktop app's webview that just navigates in place. el.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); void client.navigate( parseToRef(el.dataset.ref!), false, e.ctrlKey || e.metaKey, ); }); }); div.querySelectorAll("button[data-onclick]").forEach((el_) => { const el = el_ as HTMLElement; const onclick = el.dataset.onclick!; const parsedOnclick = JSON.parse(onclick); if (parsedOnclick[0] === "command") { const command = parsedOnclick[1]; el.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); console.info("Command link clicked in widget, running", parsedOnclick); client.runCommandByName(command, parsedOnclick[2]).catch(console.error); }); } }); // Implement task toggling div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => { const taskRef = el.dataset.externalTaskRef; const input = el.querySelector("input[type=checkbox]"); if (input) { input.addEventListener("click", (e: any) => { // Avoid triggering the click on the parent e.stopPropagation(); }); input.addEventListener("change", (e: any) => { e.stopPropagation(); const oldState = e.target.dataset.state; const newState = oldState === " " ? "x" : " "; // Update state in DOM as well for future toggles e.target.dataset.state = newState; console.log("Toggling task", taskRef); client.clientSystem .localSyscall("system.invokeFunction", [ "index.updateTaskState", taskRef, oldState, newState, ]) .catch(console.error); }); } // Extended task states (e.g. [PLANNED], [TODO]) const taskStateSpan = el.querySelector( "span.sb-task-state[data-task-state]", ); if (taskStateSpan) { taskStateSpan.style.cursor = "pointer"; taskStateSpan.addEventListener("mousedown", (e: any) => { if (e.altKey) { // Pass to the mouse down handler for Alt+Click cursor move return; } // Prevent CodeMirror from moving selection to widget source e.preventDefault(); e.stopPropagation(); }); taskStateSpan.addEventListener("click", (e: any) => { if (e.altKey) { // Alt+Click is for cursor positioning return; } e.stopPropagation(); const oldState = taskStateSpan.dataset.taskState; console.log("Cycling extended task", taskRef, oldState); client.clientSystem .localSyscall("system.invokeFunction", [ "index.cycleTaskStateByRef", taskRef, oldState, ]) .then((newState: string) => { taskStateSpan.dataset.taskState = newState; taskStateSpan.textContent = newState; }) .catch(console.error); }); } }); // Disable non-referenced task checkboxes div.querySelectorAll("input[type=checkbox]").forEach((cb) => { if (!cb.closest("span[data-external-task-ref]")) { cb.setAttribute("disabled", "disabled"); } }); if (events) { for (const [eventName, event] of Object.entries(events)) { div.addEventListener(eventName, (e) => { event({ name: eventName, data: e }); }); } } }