Add copy to clipboard button to code blocks (#735)
parent
1012282cd4
commit
aaad3dbf0a
|
@ -0,0 +1,125 @@
|
||||||
|
import { Client } from "../client.ts";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
Range,
|
||||||
|
syntaxTree,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
} from "../deps.ts";
|
||||||
|
|
||||||
|
const ICON_SVG =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></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<Decoration>[] = [];
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
|
@ -44,6 +44,7 @@ import { Compartment, Extension } from "@codemirror/state";
|
||||||
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
|
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
|
||||||
import { parseCommand } from "$common/command.ts";
|
import { parseCommand } from "$common/command.ts";
|
||||||
import { safeRun } from "$lib/async.ts";
|
import { safeRun } from "$lib/async.ts";
|
||||||
|
import { codeCopyPlugin } from "./cm_plugins/code_copy.ts";
|
||||||
|
|
||||||
export function createEditorState(
|
export function createEditorState(
|
||||||
client: Client,
|
client: Client,
|
||||||
|
@ -105,6 +106,7 @@ export function createEditorState(
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
inlineImagesPlugin(client),
|
inlineImagesPlugin(client),
|
||||||
|
codeCopyPlugin(client),
|
||||||
highlightSpecialChars(),
|
highlightSpecialChars(),
|
||||||
history(),
|
history(),
|
||||||
drawSelection(),
|
drawSelection(),
|
||||||
|
|
|
@ -395,6 +395,17 @@
|
||||||
padding-right: 7px;
|
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 {
|
.sb-line-fenced-code .sb-code {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue