From 173e25dadcdface7e9880c42386d245f48570275 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 12 Jun 2026 22:31:59 +0200 Subject: [PATCH] Add a copy button for code snippets (#107) WIP: Copy button for code snippets Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/107 --- src/lib/components/Editor.svelte | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bf80aef..06c829a 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -884,6 +884,53 @@ }, }); + const COPY_ICON = ``; + const CHECK_ICON = ``; + + const CopyButtonExtension = Extension.create({ + name: 'codeBlockCopyButton', + addProseMirrorPlugins() { + function buildDecorations(doc: any): DecorationSet { + const decos: any[] = []; + doc.descendants((node: any, pos: number) => { + if (node.type.name === 'codeBlock') { + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; + btn.title = 'Copy code'; + btn.innerHTML = COPY_ICON; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(node.textContent).then(() => { + btn.innerHTML = CHECK_ICON; + btn.classList.add('copied'); + setTimeout(() => { + btn.innerHTML = COPY_ICON; + btn.classList.remove('copied'); + }, 1500); + }); + }); + decos.push(Decoration.widget(pos + 1, btn, { side: -1, key: `copy-btn:${pos}` })); + } + }); + return DecorationSet.create(doc, decos); + } + const pluginKey = new PluginKey('codeBlockCopyButton'); + return [ + new Plugin({ + key: pluginKey, + state: { + init: (_, state) => buildDecorations(state.doc), + apply: (tr, old) => { if (!tr.docChanged) return old; return buildDecorations(tr.doc); }, + }, + props: { + decorations(state) { return pluginKey.getState(state); }, + }, + }), + ]; + }, + }); + let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null); function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) { @@ -2972,6 +3019,7 @@ Color, CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, defaultLanguage: 'text' }), CodeBlockLanguageSelect, + CopyButtonExtension, MermaidRenderer, PdfEmbed, MathBlock, @@ -6745,6 +6793,43 @@ line-height: 1.5; } + :global(.tiptap-wrapper .tiptap .code-copy-btn) { + position: absolute; + top: 6px; + right: 76px; + padding: 3px 6px; + border-radius: 5px; + background: transparent; + border: 1px solid transparent; + color: var(--text-tertiary); + cursor: pointer; + opacity: 0.35; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + line-height: 0; + transition: opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s; + } + + :global(.tiptap-wrapper .tiptap .code-copy-btn svg) { + width: 1em; + height: 1em; + } + + :global(.tiptap-wrapper .tiptap pre:hover .code-copy-btn) { + opacity: 1; + background: var(--bg-secondary); + border-color: var(--border-color); + } + + :global(.tiptap-wrapper .tiptap .code-copy-btn.copied) { + opacity: 1; + color: var(--text-accent); + background: var(--accent-light); + border-color: var(--text-accent); + } + :global(.tiptap-wrapper .tiptap pre)::after { content: attr(data-language); position: absolute;