From aaad3dbf0afb44a63546560a50511a3e8bd63397 Mon Sep 17 00:00:00 2001 From: Joe Krill Date: Fri, 23 Feb 2024 04:12:48 -0500 Subject: [PATCH] Add copy to clipboard button to code blocks (#735) --- web/cm_plugins/code_copy.ts | 125 ++++++++++++++++++++++++++++++++++++ web/editor_state.ts | 2 + web/styles/editor.scss | 11 ++++ 3 files changed, 138 insertions(+) create mode 100644 web/cm_plugins/code_copy.ts diff --git a/web/cm_plugins/code_copy.ts b/web/cm_plugins/code_copy.ts new file mode 100644 index 00000000..efc85831 --- /dev/null +++ b/web/cm_plugins/code_copy.ts @@ -0,0 +1,125 @@ +import { Client } from "../client.ts"; +import { + Decoration, + DecorationSet, + EditorView, + Range, + syntaxTree, + ViewPlugin, + ViewUpdate, + WidgetType, +} from "../deps.ts"; + +const ICON_SVG = + ''; + +const EXCLUDE_LANGUAGES = ["template", "query", "embed"]; + +class CodeCopyWidget extends WidgetType { + constructor(readonly value: string, readonly client: Client) { + super(); + } + + eq(other: CodeCopyWidget) { + return other.value == this.value; + } + + toDOM() { + const wrap = document.createElement("span"); + wrap.setAttribute("aria-hidden", "true"); + wrap.className = "sb-actions"; + + const button = wrap.appendChild(document.createElement("button")); + button.type = "button"; + button.title = "Copy to clipboard"; + button.className = "sb-code-copy-button"; + button.innerHTML = ICON_SVG; + button.title = "Copy"; + button.onclick = (e) => { + e.stopPropagation(); + e.preventDefault(); + navigator.clipboard.writeText(this.value) + .catch((err) => { + this.client.flashNotification( + `Error copying to clipboard: ${err}`, + "error", + ); + }) + .then(() => { + this.client.flashNotification("Copied to cliboard", "info"); + }); + }; + + return wrap; + } + + ignoreEvent() { + return true; + } +} + +function codeCopyDecoration( + view: EditorView, + client: Client, +) { + const widgets: Range[] = []; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name == "FencedCode") { + const textNode = node.node.getChild("CodeText"); + const infoNode = node.node.getChild("CodeInfo"); + + if (!textNode) { + return; + } + + const language = infoNode + ? view.state.doc.sliceString( + infoNode.from, + infoNode.to, + ) + : undefined; + + if (language && EXCLUDE_LANGUAGES.includes(language)) { + return; + } + + const text = view.state.doc.sliceString(textNode.from, textNode.to); + const deco = Decoration.widget({ + widget: new CodeCopyWidget(text, client), + side: 0, + }); + widgets.push(deco.range(node.from)); + } + }, + }); + } + return Decoration.set(widgets); +} + +export const codeCopyPlugin = (client: Client) => { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = codeCopyDecoration(view, client); + } + + update(update: ViewUpdate) { + if ( + update.docChanged || update.viewportChanged || + syntaxTree(update.startState) != syntaxTree(update.state) + ) { + this.decorations = codeCopyDecoration(update.view, client); + } + } + }, + { + decorations: (v) => v.decorations, + }, + ); +}; diff --git a/web/editor_state.ts b/web/editor_state.ts index c95cbcec..9e925741 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -44,6 +44,7 @@ import { Compartment, Extension } from "@codemirror/state"; import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; import { parseCommand } from "$common/command.ts"; import { safeRun } from "$lib/async.ts"; +import { codeCopyPlugin } from "./cm_plugins/code_copy.ts"; export function createEditorState( client: Client, @@ -105,6 +106,7 @@ export function createEditorState( ], }), inlineImagesPlugin(client), + codeCopyPlugin(client), highlightSpecialChars(), history(), drawSelection(), diff --git a/web/styles/editor.scss b/web/styles/editor.scss index abe4727b..6723730f 100644 --- a/web/styles/editor.scss +++ b/web/styles/editor.scss @@ -395,6 +395,17 @@ padding-right: 7px; } + .sb-code-copy-button { + float: right; + cursor: pointer; + margin: 0 3px; + } + + .sb-code-copy-button > svg { + height: 1rem; + width: 1rem; + } + .sb-line-fenced-code .sb-code { background-color: transparent; }