From cca48f66cdfe0a93a5f6d6ce32df609d66a1ab99 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Thu, 22 Dec 2022 16:20:05 +0100 Subject: [PATCH] Fixes #158 --- common/manifest.ts | 2 + plugs/markdown/markdown.plug.yaml | 6 +- plugs/markdown/widget.ts | 19 ++++ web/cm_plugins/block.ts | 17 --- web/cm_plugins/clean.ts | 2 + web/cm_plugins/fenced_code.ts | 176 ++++++++++++++++++++++++++++++ web/components/panel.tsx | 31 ++++-- web/editor.tsx | 14 +-- web/hooks/code_widget.ts | 57 ++++++++++ web/styles/editor.scss | 16 +++ 10 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 plugs/markdown/widget.ts create mode 100644 web/cm_plugins/fenced_code.ts create mode 100644 web/hooks/code_widget.ts diff --git a/common/manifest.ts b/common/manifest.ts index d737037..f0ce2ad 100644 --- a/common/manifest.ts +++ b/common/manifest.ts @@ -5,6 +5,7 @@ import { EventHookT } from "../plugos/hooks/event.ts"; import { CommandHookT } from "../web/hooks/command.ts"; import { SlashCommandHookT } from "../web/hooks/slash_command.ts"; import { PageNamespaceHookT } from "../server/hooks/page_namespace.ts"; +import { CodeWidgetT } from "../web/hooks/code_widget.ts"; export type SilverBulletHooks = & CommandHookT @@ -12,6 +13,7 @@ export type SilverBulletHooks = & EndpointHookT & CronHookT & EventHookT + & CodeWidgetT & PageNamespaceHookT; export type SyntaxExtensions = { diff --git a/plugs/markdown/markdown.plug.yaml b/plugs/markdown/markdown.plug.yaml index f33a031..2bd5245 100644 --- a/plugs/markdown/markdown.plug.yaml +++ b/plugs/markdown/markdown.plug.yaml @@ -30,4 +30,8 @@ functions: sharePublisher: path: ./share.ts:sharePublisher events: - - share:file \ No newline at end of file + - share:file + + markdownWidget: + path: ./widget.ts:markdownWidget + codeWidget: markdown \ No newline at end of file diff --git a/plugs/markdown/widget.ts b/plugs/markdown/widget.ts new file mode 100644 index 0000000..1011775 --- /dev/null +++ b/plugs/markdown/widget.ts @@ -0,0 +1,19 @@ +import { parseMarkdown } from "$sb/silverbullet-syscall/markdown.ts"; +import { renderMarkdownToHtml } from "./markdown_render.ts"; + +export async function markdownWidget( + bodyText: string, +): Promise<{ html: string; script: string }> { + const mdTree = await parseMarkdown(bodyText); + + const html = renderMarkdownToHtml(mdTree, { + smartHardBreak: true, + }); + return Promise.resolve({ + html: html, + script: `updateHeight(); + document.addEventListener("click", () => { + api({type: "blur"}); + });`, + }); +} diff --git a/web/cm_plugins/block.ts b/web/cm_plugins/block.ts index c0c5c70..367395c 100644 --- a/web/cm_plugins/block.ts +++ b/web/cm_plugins/block.ts @@ -52,23 +52,6 @@ function hideNodes(state: EditorState) { } } } - - if ( - node.name === "CodeMark" - ) { - const parent = node.node.parent!; - // Hide ONLY if CodeMark is not insine backticks (InlineCode) and the cursor is placed outside - if ( - parent.node.name !== "InlineCode" && - !isCursorInRange(state, [parent.from, parent.to]) - ) { - widgets.push( - Decoration.line({ - class: "sb-line-code-outside", - }).range(node.from), - ); - } - } }, }); return Decoration.set(widgets, true); diff --git a/web/cm_plugins/clean.ts b/web/cm_plugins/clean.ts index e16208c..3aaed0b 100644 --- a/web/cm_plugins/clean.ts +++ b/web/cm_plugins/clean.ts @@ -12,6 +12,7 @@ import { tablePlugin } from "./table.ts"; import { taskListPlugin } from "./task.ts"; import { cleanWikiLinkPlugin } from "./wiki_link.ts"; import { cleanCommandLinkPlugin } from "./command_link.ts"; +import { fencedCodePlugin } from "./fenced_code.ts"; export function cleanModePlugins(editor: Editor) { return [ @@ -22,6 +23,7 @@ export function cleanModePlugins(editor: Editor) { hideMarksPlugin(), hideHeaderMarkPlugin(), cleanBlockPlugin(), + fencedCodePlugin(editor), taskListPlugin({ // TODO: Move this logic elsewhere? onCheckboxClick: (pos) => { diff --git a/web/cm_plugins/fenced_code.ts b/web/cm_plugins/fenced_code.ts new file mode 100644 index 0000000..bd04248 --- /dev/null +++ b/web/cm_plugins/fenced_code.ts @@ -0,0 +1,176 @@ +import { panelHtml } from "../components/panel.tsx"; +import { Decoration, EditorState, syntaxTree, WidgetType } from "../deps.ts"; +import type { Editor } from "../editor.tsx"; +import { CodeWidgetCallback } from "../hooks/code_widget.ts"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +class IFrameWidget extends WidgetType { + constructor( + readonly from: number, + readonly to: number, + readonly editor: Editor, + readonly bodyText: string, + readonly codeWidgetCallback: CodeWidgetCallback, + ) { + super(); + } + + toDOM(): HTMLElement { + const iframe = document.createElement("iframe"); + iframe.srcdoc = panelHtml; + // iframe.style.height = "0"; + + const messageListener = (evt: any) => { + if (evt.source !== iframe.contentWindow) { + return; + } + const data = evt.data; + if (!data) { + return; + } + switch (data.type) { + case "event": + this.editor.dispatchAppEvent(data.name, ...data.args); + break; + case "setHeight": + iframe.style.height = data.height + "px"; + break; + case "setBody": + this.editor.editorView!.dispatch({ + changes: { + from: this.from, + to: this.to, + insert: data.body, + }, + }); + break; + case "blur": + this.editor.editorView!.dispatch({ + selection: { anchor: this.from }, + }); + this.editor.focus(); + break; + } + }; + + iframe.onload = () => { + // Subscribe to message event on global object (to receive messages from iframe) + globalThis.addEventListener("message", messageListener); + this.codeWidgetCallback(this.bodyText).then(({ html, script }) => { + iframe.contentWindow!.postMessage({ + type: "html", + html, + script, + }); + iframe.contentWindow!.onunload = () => { + // Unsubscribing from events + globalThis.removeEventListener("message", messageListener); + }; + }); + }; + return iframe; + } + + eq(other: WidgetType): boolean { + return ( + other instanceof IFrameWidget && + other.bodyText === this.bodyText + ); + } +} + +export function fencedCodePlugin(editor: Editor) { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter({ from, to, name, node }) { + if (name === "FencedCode") { + if (isCursorInRange(state, [from, to])) return; + const text = state.sliceDoc(from, to); + const [_, lang] = text.match(/^```(\w+)?/)!; + const codeWidgetCallback = editor.codeWidgetHook.codeWidgetCallbacks + .get(lang); + if (codeWidgetCallback) { + // We got a custom renderer! + const lineStrings = text.split("\n"); + + const lines: { from: number; to: number }[] = []; + let fromIt = from; + for (const line of lineStrings) { + lines.push({ + from: fromIt, + to: fromIt + line.length, + }); + fromIt += line.length + 1; + } + + const firstLine = lines[0], lastLine = lines[lines.length - 1]; + + // In case of doubt, back out + if (!firstLine || !lastLine) return; + + widgets.push( + invisibleDecoration.range(firstLine.from, firstLine.to), + ); + widgets.push( + invisibleDecoration.range(lastLine.from, lastLine.to), + ); + widgets.push( + Decoration.line({ + class: "sb-fenced-code-iframe", + }).range(firstLine.from), + ); + widgets.push( + Decoration.line({ + class: "sb-fenced-code-hide", + }).range(lastLine.from), + ); + + lines.slice(1, lines.length - 1).forEach((line) => { + widgets.push( + Decoration.line({ class: "sb-line-table-outside" }).range( + line.from, + ), + ); + }); + + widgets.push( + Decoration.widget({ + widget: new IFrameWidget( + from + lineStrings[0].length + 1, + to - lineStrings[lineStrings.length - 1].length - 1, + editor, + lineStrings.slice(1, lineStrings.length - 1).join("\n"), + codeWidgetCallback, + ), + }).range(from), + ); + return false; + } + return true; + } + if ( + name === "CodeMark" + ) { + const parent = node.parent!; + // Hide ONLY if CodeMark is not insine backticks (InlineCode) and the cursor is placed outside + if ( + parent.node.name !== "InlineCode" && + !isCursorInRange(state, [parent.from, parent.to]) + ) { + widgets.push( + Decoration.line({ + class: "sb-line-code-outside", + }).range(node.from), + ); + } + } + }, + }); + return Decoration.set(widgets, true); + }); +} diff --git a/web/components/panel.tsx b/web/components/panel.tsx index 1f65690..bb10cce 100644 --- a/web/components/panel.tsx +++ b/web/components/panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "../deps.ts"; import { Editor } from "../editor.tsx"; import { PanelConfig } from "../types.ts"; -const panelHtml = ` +export const panelHtml = ` @@ -25,14 +25,27 @@ window.addEventListener("message", (message) => { }); function sendEvent(name, ...args) { - window.parent.postMessage( - { - type: "event", - name, - args, - }, - "*" - ); + window.parent.postMessage({ type: "event", name, args, }, "*"); +} +function api(obj) { + window.parent.postMessage(obj, "*"); +} +function updateHeight() { + api({ + type: "setHeight", + height: document.documentElement.offsetHeight, + }); +} + +function loadJsByUrl(url) { + const script = document.createElement("script"); + script.src = url; + + return new Promise((resolve) => { + script.onload = resolve; + + document.documentElement.firstChild.appendChild(script); + }); } diff --git a/web/editor.tsx b/web/editor.tsx index 3da79e1..f700c43 100644 --- a/web/editor.tsx +++ b/web/editor.tsx @@ -89,19 +89,13 @@ import { storeSyscalls } from "./syscalls/store.ts"; import { systemSyscalls } from "./syscalls/system.ts"; import { AppViewState, BuiltinSettings, initialViewState } from "./types.ts"; -// Third-party dependencies -// PlugOS Dependencies -// Syscalls -// State and state transitions import type { AppEvent, ClickEvent, CompleteEvent, } from "../plug-api/app_event.ts"; +import { CodeWidgetHook } from "./hooks/code_widget.ts"; -// UI Components -// CodeMirror plugins -// Real-time collaboration const frontMatterRegex = /^---\n(.*?)---\n/ms; class PageState { @@ -124,6 +118,8 @@ export class Editor { space: Space; pageNavigator: PathPageNavigator; eventHook: EventHook; + codeWidgetHook: CodeWidgetHook; + saveTimeout: any; debouncedUpdateEvent = throttle(() => { this.eventHook @@ -157,6 +153,10 @@ export class Editor { this.eventHook = new EventHook(); this.system.addHook(this.eventHook); + // Code widget hook + this.codeWidgetHook = new CodeWidgetHook(); + this.system.addHook(this.codeWidgetHook); + // Command hook this.commandHook = new CommandHook(); this.commandHook.on({ diff --git a/web/hooks/code_widget.ts b/web/hooks/code_widget.ts new file mode 100644 index 0000000..6c76ea8 --- /dev/null +++ b/web/hooks/code_widget.ts @@ -0,0 +1,57 @@ +import { Hook, Manifest } from "../../plugos/types.ts"; +import { System } from "../../plugos/system.ts"; + +export type CodeWidgetT = { + codeWidget?: string; +}; + +export type CodeWidgetCallback = ( + bodyText: string, +) => Promise<{ html: string; script: string }>; + +export class CodeWidgetHook implements Hook { + codeWidgetCallbacks = new Map(); + + constructor() { + } + + collectAllCodeWidgets(system: System) { + this.codeWidgetCallbacks.clear(); + for (const plug of system.loadedPlugs.values()) { + for ( + const [name, functionDef] of Object.entries( + plug.manifest!.functions, + ) + ) { + if (!functionDef.codeWidget) { + continue; + } + this.codeWidgetCallbacks.set(functionDef.codeWidget, (bodyText) => { + return plug.invoke(name, [bodyText]); + }); + } + } + } + + apply(system: System): void { + this.collectAllCodeWidgets(system); + system.on({ + plugLoaded: () => { + this.collectAllCodeWidgets(system); + }, + }); + } + + validateManifest(manifest: Manifest): string[] { + const errors = []; + for (const functionDef of Object.values(manifest.functions)) { + if (!functionDef.codeWidget) { + continue; + } + if (typeof functionDef.codeWidget !== "string") { + errors.push(`Codewidgets require a string name.`); + } + } + return errors; + } +} diff --git a/web/styles/editor.scss b/web/styles/editor.scss index 69f516b..c18b6ac 100644 --- a/web/styles/editor.scss +++ b/web/styles/editor.scss @@ -211,6 +211,22 @@ } } + .sb-fenced-code-hide { + background-color: transparent !important; + line-height: 0; + } + + .sb-fenced-code-iframe { + background-color: transparent !important; + + iframe { + border: 0; + width: 100%; + padding: 0; + margin: 0; + } + } + .sb-line-blockquote { border-left: 1px solid rgb(74, 74, 74); }