Monorepo for Tangled tangled.org

[feature] - Add copy-to-clipboard button for markdown code blocks #1114

Summary#

  • Injects a copy button into .prose pre.chroma code blocks via client-side DOM injection in base.html
  • Scoped to .prose so blob view source code (which has its own UX) is unaffected
  • Handles HTMX-swapped content (dynamic comment loading) via htmx:afterSettle listener

Test plan#

  • go test ./appview/pages/markup/... passes (no regressions)
  • Verify button appears on hover over code blocks in rendered markdown
  • Verify clicking copies code and shows check icon briefly
  • Verify dark mode styling
  • Verify blob view code blocks do NOT get copy buttons
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:c7frv4rcitff3p2nh7of5bcv/sh.tangled.repo.pull/3mgbmydvrsw22
+188 -75
Interdiff #1 โ†’ #2
+11 -35
appview/pages/templates/layouts/base.html
··· 99 99 {{ end }} 100 100 101 101 <script> 102 - (function() { 103 - var copyIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>'; 104 - var checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'; 105 - 106 - function addCopyButtons(root) { 107 - var blocks = (root || document).querySelectorAll('.prose pre.chroma'); 108 - blocks.forEach(function(pre) { 109 - if (pre.querySelector('.code-copy-btn')) return; 110 - pre.style.position = 'relative'; 111 - var btn = document.createElement('button'); 112 - btn.className = 'code-copy-btn'; 113 - btn.type = 'button'; 114 - btn.title = 'Copy code'; 115 - btn.setAttribute('aria-label', 'Copy code to clipboard'); 116 - btn.innerHTML = copyIcon; 117 - btn.addEventListener('click', function() { 118 - var code = pre.querySelector('code'); 119 - var text = code ? code.innerText : pre.innerText; 120 - navigator.clipboard.writeText(text).then(function() { 121 - btn.innerHTML = checkIcon; 122 - btn.classList.add('copied'); 123 - setTimeout(function() { 124 - btn.innerHTML = copyIcon; 125 - btn.classList.remove('copied'); 126 - }, 2000); 127 - }); 128 - }); 129 - pre.appendChild(btn); 130 - }); 131 - } 132 - 133 - addCopyButtons(); 134 - document.body.addEventListener('htmx:afterSettle', function(e) { 135 - addCopyButtons(e.detail.elt); 102 + document.addEventListener('click', function(e) { 103 + var btn = e.target.closest('.code-copy-btn'); 104 + if (!btn) return; 105 + var pre = btn.closest('.code-copy-wrapper').querySelector('pre'); 106 + if (!pre) return; 107 + var code = pre.querySelector('code'); 108 + var text = code ? code.innerText : pre.innerText; 109 + navigator.clipboard.writeText(text).then(function() { 110 + btn.classList.add('copied'); 111 + setTimeout(function() { btn.classList.remove('copied'); }, 2000); 136 112 }); 137 - })(); 113 + }); 138 114 </script> 139 115 </body> 140 116 </html>
+22 -33
input.css
··· 220 220 @apply flex justify-center my-4 overflow-x-auto bg-transparent border-0; 221 221 } 222 222 223 - .prose pre.chroma { 224 - position: relative; 223 + .code-copy-wrapper { 224 + @apply relative; 225 225 } 226 226 227 227 .code-copy-btn { 228 - position: absolute; 229 - top: 0.5rem; 230 - right: 0.5rem; 231 - padding: 0.375rem; 232 - border: none; 233 - border-radius: 0.25rem; 234 - cursor: pointer; 235 - color: #6c6f85; 236 - background: rgba(239, 241, 245, 0.8); 237 - opacity: 0; 238 - transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease; 239 - line-height: 0; 240 - z-index: 1; 228 + @apply absolute top-2 right-2 p-1.5 rounded cursor-pointer; 229 + @apply text-gray-500 bg-gray-200/80 dark:text-gray-400 dark:bg-gray-800/80; 230 + @apply opacity-0 transition-opacity duration-150 ease-in-out; 231 + @apply leading-none border-0 z-10; 241 232 } 242 233 243 - .prose pre.chroma:hover .code-copy-btn, 234 + .code-copy-wrapper:hover .code-copy-btn, 244 235 .code-copy-btn:focus { 245 - opacity: 1; 236 + @apply opacity-100; 246 237 } 247 238 248 239 .code-copy-btn:hover { 249 - background: rgba(239, 241, 245, 1); 250 - color: #4c4f69; 240 + @apply bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-200; 251 241 } 252 242 253 243 .code-copy-btn.copied { 254 - color: #40a02b; 244 + @apply text-green-600 dark:text-green-400; 245 + } 246 + 247 + .code-copy-btn .check-icon { 248 + @apply hidden; 249 + } 250 + 251 + .code-copy-btn.copied .copy-icon { 252 + @apply hidden; 253 + } 254 + 255 + .code-copy-btn.copied .check-icon { 256 + @apply block; 255 257 } 256 258 257 259 /* Base callout */ ··· 1043 1045 text-decoration: underline; 1044 1046 } 1045 1047 1046 - .code-copy-btn { 1047 - color: #8087a2; 1048 - background: rgba(36, 39, 58, 0.8); 1049 - } 1050 - 1051 - .code-copy-btn:hover { 1052 - background: rgba(36, 39, 58, 1); 1053 - color: #cad3f5; 1054 - } 1055 - 1056 - .code-copy-btn.copied { 1057 - color: #a6da95; 1058 - } 1059 1048 } 1060 1049 1061 1050 actor-typeahead {
+134
appview/pages/markup/extension/codecopy.go
··· 1 + package extension 2 + 3 + import ( 4 + "fmt" 5 + "io/fs" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/yuin/goldmark" 10 + "github.com/yuin/goldmark/ast" 11 + "github.com/yuin/goldmark/parser" 12 + "github.com/yuin/goldmark/renderer" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // KindCodeCopyBlock is a NodeKind of the CodeCopyBlock node. 18 + var KindCodeCopyBlock = ast.NewNodeKind("CodeCopyBlock") 19 + 20 + // CodeCopyBlock wraps a FencedCodeBlock to add a copy-to-clipboard button. 21 + type CodeCopyBlock struct { 22 + ast.BaseBlock 23 + } 24 + 25 + var _ ast.Node = (*CodeCopyBlock)(nil) 26 + 27 + func (n *CodeCopyBlock) Kind() ast.NodeKind { 28 + return KindCodeCopyBlock 29 + } 30 + 31 + func (n *CodeCopyBlock) Dump(source []byte, level int) { 32 + ast.DumpHelper(n, source, level, nil, nil) 33 + } 34 + 35 + // codeCopyTransformer wraps FencedCodeBlock nodes in CodeCopyBlock nodes. 36 + type codeCopyTransformer struct{} 37 + 38 + func (t *codeCopyTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 39 + var fencedBlocks []*ast.FencedCodeBlock 40 + 41 + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 42 + if !entering { 43 + return ast.WalkContinue, nil 44 + } 45 + if fcb, ok := n.(*ast.FencedCodeBlock); ok { 46 + lang := string(fcb.Language(reader.Source())) 47 + if lang != "mermaid" { 48 + fencedBlocks = append(fencedBlocks, fcb) 49 + } 50 + } 51 + return ast.WalkContinue, nil 52 + }) 53 + 54 + for _, fcb := range fencedBlocks { 55 + wrapper := &CodeCopyBlock{} 56 + parent := fcb.Parent() 57 + parent.ReplaceChild(parent, fcb, wrapper) 58 + wrapper.AppendChild(wrapper, fcb) 59 + } 60 + } 61 + 62 + // codeCopyRenderer renders the CodeCopyBlock wrapper with a copy button. 63 + type codeCopyRenderer struct { 64 + copyIconSVG string 65 + checkIconSVG string 66 + } 67 + 68 + func (r *codeCopyRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 69 + reg.Register(KindCodeCopyBlock, r.renderCodeCopyBlock) 70 + } 71 + 72 + func (r *codeCopyRenderer) renderCodeCopyBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 73 + if entering { 74 + _, _ = w.WriteString(`<div class="code-copy-wrapper">`) 75 + } else { 76 + _, _ = w.WriteString(fmt.Sprintf( 77 + `<button class="code-copy-btn" type="button" aria-label="Copy code to clipboard">`+ 78 + `<span class="copy-icon">%s</span>`+ 79 + `<span class="check-icon">%s</span>`+ 80 + `</button></div>`, 81 + r.copyIconSVG, r.checkIconSVG, 82 + )) 83 + } 84 + return ast.WalkContinue, nil 85 + } 86 + 87 + // CodeCopyExt is a goldmark extension that adds copy-to-clipboard buttons to fenced code blocks. 88 + type CodeCopyExt struct { 89 + files fs.FS 90 + } 91 + 92 + func NewCodeCopyExt(files fs.FS) *CodeCopyExt { 93 + return &CodeCopyExt{files: files} 94 + } 95 + 96 + func (e *CodeCopyExt) Extend(md goldmark.Markdown) { 97 + copyIcon := readIcon(e.files, "copy", "size-4") 98 + checkIcon := readIcon(e.files, "check", "size-4") 99 + 100 + md.Parser().AddOptions( 101 + parser.WithASTTransformers( 102 + util.Prioritized(&codeCopyTransformer{}, 500), 103 + ), 104 + ) 105 + md.Renderer().AddOptions( 106 + renderer.WithNodeRenderers( 107 + util.Prioritized(&codeCopyRenderer{ 108 + copyIconSVG: copyIcon, 109 + checkIconSVG: checkIcon, 110 + }, 500), 111 + ), 112 + ) 113 + } 114 + 115 + func readIcon(files fs.FS, name string, classes ...string) string { 116 + if files == nil { 117 + return "" 118 + } 119 + 120 + iconPath := filepath.Join("static", "icons", name+".svg") 121 + data, err := fs.ReadFile(files, iconPath) 122 + if err != nil { 123 + return "" 124 + } 125 + 126 + svgStr := string(data) 127 + svgTagEnd := strings.Index(svgStr, ">") 128 + if svgTagEnd == -1 { 129 + return svgStr 130 + } 131 + 132 + classTag := ` class="` + strings.Join(classes, " ") + `"` 133 + return svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 134 + }
+3 -2
appview/pages/markup/markdown.go
··· 53 53 Files fs.FS 54 54 } 55 55 56 - func NewMarkdown(hostname string) goldmark.Markdown { 56 + func NewMarkdown(hostname string, files fs.FS) goldmark.Markdown { 57 57 md := goldmark.New( 58 58 goldmark.WithExtensions( 59 59 extension.GFM, ··· 74 74 callout.CalloutExtention, 75 75 textension.AtExt, 76 76 textension.NewTangledLinkExt(hostname), 77 + textension.NewCodeCopyExt(files), 77 78 emoji.Emoji, 78 79 ), 79 80 goldmark.WithParserOptions( ··· 85 86 } 86 87 87 88 func (rctx *RenderContext) RenderMarkdown(source string) string { 88 - return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname)) 89 + return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname, rctx.Files)) 89 90 } 90 91 91 92 func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
+3 -3
appview/pages/markup/markdown_test.go
··· 33 33 34 34 for _, tt := range tests { 35 35 t.Run(tt.name, func(t *testing.T) { 36 - md := NewMarkdown("tangled.org") 36 + md := NewMarkdown("tangled.org", nil) 37 37 38 38 var buf bytes.Buffer 39 39 if err := md.Convert([]byte(tt.markdown), &buf); err != nil { ··· 96 96 97 97 for _, tt := range tests { 98 98 t.Run(tt.name, func(t *testing.T) { 99 - md := NewMarkdown("tangled.org") 99 + md := NewMarkdown("tangled.org", nil) 100 100 101 101 var buf bytes.Buffer 102 102 if err := md.Convert([]byte(tt.markdown), &buf); err != nil { ··· 151 151 152 152 for _, tt := range tests { 153 153 t.Run(tt.name, func(t *testing.T) { 154 - md := NewMarkdown("tangled.org") 154 + md := NewMarkdown("tangled.org", nil) 155 155 156 156 var buf bytes.Buffer 157 157 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
+1 -1
appview/pages/markup/reference_link.go
··· 22 22 var ( 23 23 refLinkSet = make(map[models.ReferenceLink]struct{}) 24 24 mentionsSet = make(map[string]struct{}) 25 - md = NewMarkdown(host) 25 + md = NewMarkdown(host, nil) 26 26 sourceBytes = []byte(source) 27 27 root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 28 )
+13
appview/pages/markup/sanitizer.go
··· 120 120 // goldmark-callout 121 121 policy.AllowAttrs("data-callout").OnElements("details") 122 122 123 + // code copy button 124 + policy.AllowElements("button") 125 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`code-copy-btn`)).OnElements("button") 126 + policy.AllowAttrs("type", "aria-label").OnElements("button") 127 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`code-copy-wrapper`)).OnElements("div") 128 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`copy-icon|check-icon`)).OnElements("span") 129 + 130 + // SVG elements for code copy icons 131 + policy.AllowElements("svg", "path", "rect") 132 + policy.AllowAttrs("xmlns", "viewBox", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class").OnElements("svg") 133 + policy.AllowAttrs("d").OnElements("path") 134 + policy.AllowAttrs("x", "y", "rx", "ry").OnElements("rect") 135 + 123 136 return policy 124 137 } 125 138

History

5 rounds 5 comments
sign up or login to add to the discussion
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
no conflicts, ready to merge
expand 2 comments

works brilliantly! one nit: the copy button seems to add an extra newline for each line on my browser. do you notice the same behavior on your end?

will do a more thorough code review shortly!

I still need to look into this!! I asked Claude in the meantime, and it thinks you're right - there's an unnecessary carriage return

1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 3 comments

Thank you for the contribution! Few feedbacks:

  • here we can use icon template like others.
  • can we use tailwindcss for styling? using @applysyntax in css file.

i would prefer if we added this button using a goldmark extension instead of a clientside script!

Thank you for the feedback! I've updating the diff to use goldmark and apply tailwind classes.

1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments