11import { useMarked } from "../context/marked"
2+ import { useI18n } from "../context/i18n"
23import DOMPurify from "dompurify"
34import { checksum } from "@opencode-ai/util/encode"
4- import { ComponentProps , createResource , splitProps } from "solid-js"
5+ import { ComponentProps , createEffect , createResource , createSignal , onCleanup , splitProps } from "solid-js"
56import { isServer } from "solid-js/web"
67
78type Entry = {
@@ -32,11 +33,120 @@ const config = {
3233 FORBID_CONTENTS : [ "style" , "script" ] ,
3334}
3435
36+ const iconPaths = {
37+ copy : '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>' ,
38+ check : '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>' ,
39+ }
40+
3541function sanitize ( html : string ) {
3642 if ( ! DOMPurify . isSupported ) return ""
3743 return DOMPurify . sanitize ( html , config )
3844}
3945
46+ type CopyLabels = {
47+ copy : string
48+ copied : string
49+ }
50+
51+ function createIcon ( path : string , slot : string ) {
52+ const icon = document . createElement ( "div" )
53+ icon . setAttribute ( "data-component" , "icon" )
54+ icon . setAttribute ( "data-size" , "small" )
55+ icon . setAttribute ( "data-slot" , slot )
56+ const svg = document . createElementNS ( "http://www.w3.org/2000/svg" , "svg" )
57+ svg . setAttribute ( "data-slot" , "icon-svg" )
58+ svg . setAttribute ( "fill" , "none" )
59+ svg . setAttribute ( "viewBox" , "0 0 20 20" )
60+ svg . setAttribute ( "aria-hidden" , "true" )
61+ svg . innerHTML = path
62+ icon . appendChild ( svg )
63+ return icon
64+ }
65+
66+ function createCopyButton ( labels : CopyLabels ) {
67+ const button = document . createElement ( "button" )
68+ button . type = "button"
69+ button . setAttribute ( "data-component" , "icon-button" )
70+ button . setAttribute ( "data-variant" , "secondary" )
71+ button . setAttribute ( "data-size" , "normal" )
72+ button . setAttribute ( "data-slot" , "markdown-copy-button" )
73+ button . setAttribute ( "aria-label" , labels . copy )
74+ button . setAttribute ( "title" , labels . copy )
75+ button . appendChild ( createIcon ( iconPaths . copy , "copy-icon" ) )
76+ button . appendChild ( createIcon ( iconPaths . check , "check-icon" ) )
77+ return button
78+ }
79+
80+ function setCopyState ( button : HTMLButtonElement , labels : CopyLabels , copied : boolean ) {
81+ if ( copied ) {
82+ button . setAttribute ( "data-copied" , "true" )
83+ button . setAttribute ( "aria-label" , labels . copied )
84+ button . setAttribute ( "title" , labels . copied )
85+ return
86+ }
87+ button . removeAttribute ( "data-copied" )
88+ button . setAttribute ( "aria-label" , labels . copy )
89+ button . setAttribute ( "title" , labels . copy )
90+ }
91+
92+ function setupCodeCopy ( root : HTMLDivElement , labels : CopyLabels ) {
93+ const timeouts = new Map < HTMLButtonElement , ReturnType < typeof setTimeout > > ( )
94+
95+ const updateLabel = ( button : HTMLButtonElement ) => {
96+ const copied = button . getAttribute ( "data-copied" ) === "true"
97+ setCopyState ( button , labels , copied )
98+ }
99+
100+ const ensureWrapper = ( block : HTMLPreElement ) => {
101+ const parent = block . parentElement
102+ if ( ! parent ) return
103+ const wrapped = parent . getAttribute ( "data-component" ) === "markdown-code"
104+ if ( wrapped ) return
105+ const wrapper = document . createElement ( "div" )
106+ wrapper . setAttribute ( "data-component" , "markdown-code" )
107+ parent . replaceChild ( wrapper , block )
108+ wrapper . appendChild ( block )
109+ wrapper . appendChild ( createCopyButton ( labels ) )
110+ }
111+
112+ const handleClick = async ( event : MouseEvent ) => {
113+ const target = event . target
114+ if ( ! ( target instanceof Element ) ) return
115+ const button = target . closest ( '[data-slot="markdown-copy-button"]' )
116+ if ( ! ( button instanceof HTMLButtonElement ) ) return
117+ const code = button . closest ( '[data-component="markdown-code"]' ) ?. querySelector ( "code" )
118+ const content = code ?. textContent ?? ""
119+ if ( ! content ) return
120+ const clipboard = navigator ?. clipboard
121+ if ( ! clipboard ) return
122+ await clipboard . writeText ( content )
123+ setCopyState ( button , labels , true )
124+ const existing = timeouts . get ( button )
125+ if ( existing ) clearTimeout ( existing )
126+ const timeout = setTimeout ( ( ) => setCopyState ( button , labels , false ) , 2000 )
127+ timeouts . set ( button , timeout )
128+ }
129+
130+ const blocks = Array . from ( root . querySelectorAll ( "pre" ) )
131+ for ( const block of blocks ) {
132+ ensureWrapper ( block )
133+ }
134+
135+ const buttons = Array . from ( root . querySelectorAll ( '[data-slot="markdown-copy-button"]' ) )
136+ for ( const button of buttons ) {
137+ if ( button instanceof HTMLButtonElement ) updateLabel ( button )
138+ }
139+
140+ root . addEventListener ( "click" , handleClick )
141+
142+ return ( ) => {
143+ root . removeEventListener ( "click" , handleClick )
144+ for ( const timeout of timeouts . values ( ) ) {
145+ clearTimeout ( timeout )
146+ }
147+ }
148+ }
149+
40150function touch ( key : string , value : Entry ) {
41151 cache . delete ( key )
42152 cache . set ( key , value )
@@ -58,6 +168,8 @@ export function Markdown(
58168) {
59169 const [ local , others ] = splitProps ( props , [ "text" , "cacheKey" , "class" , "classList" ] )
60170 const marked = useMarked ( )
171+ const i18n = useI18n ( )
172+ const [ root , setRoot ] = createSignal < HTMLDivElement > ( )
61173 const [ html ] = createResource (
62174 ( ) => local . text ,
63175 async ( markdown ) => {
@@ -81,6 +193,19 @@ export function Markdown(
81193 } ,
82194 { initialValue : "" } ,
83195 )
196+
197+ createEffect ( ( ) => {
198+ const container = root ( )
199+ const content = html ( )
200+ if ( ! container ) return
201+ if ( ! content ) return
202+ if ( isServer ) return
203+ const cleanup = setupCodeCopy ( container , {
204+ copy : i18n . t ( "ui.message.copy" ) ,
205+ copied : i18n . t ( "ui.message.copied" ) ,
206+ } )
207+ onCleanup ( cleanup )
208+ } )
84209 return (
85210 < div
86211 data-component = "markdown"
@@ -89,6 +214,7 @@ export function Markdown(
89214 [ local . class ?? "" ] : ! ! local . class ,
90215 } }
91216 innerHTML = { html . latest }
217+ ref = { setRoot }
92218 { ...others }
93219 />
94220 )
0 commit comments