|
|
@@ -1,7 +1,8 @@
|
|
|
import { useMarked } from "../context/marked"
|
|
|
+import { useI18n } from "../context/i18n"
|
|
|
import DOMPurify from "dompurify"
|
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
|
-import { ComponentProps, createResource, splitProps } from "solid-js"
|
|
|
+import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
|
|
import { isServer } from "solid-js/web"
|
|
|
|
|
|
type Entry = {
|
|
|
@@ -32,11 +33,120 @@ const config = {
|
|
|
FORBID_CONTENTS: ["style", "script"],
|
|
|
}
|
|
|
|
|
|
+const iconPaths = {
|
|
|
+ copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
|
|
+ check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
|
|
+}
|
|
|
+
|
|
|
function sanitize(html: string) {
|
|
|
if (!DOMPurify.isSupported) return ""
|
|
|
return DOMPurify.sanitize(html, config)
|
|
|
}
|
|
|
|
|
|
+type CopyLabels = {
|
|
|
+ copy: string
|
|
|
+ copied: string
|
|
|
+}
|
|
|
+
|
|
|
+function createIcon(path: string, slot: string) {
|
|
|
+ const icon = document.createElement("div")
|
|
|
+ icon.setAttribute("data-component", "icon")
|
|
|
+ icon.setAttribute("data-size", "small")
|
|
|
+ icon.setAttribute("data-slot", slot)
|
|
|
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
|
|
+ svg.setAttribute("data-slot", "icon-svg")
|
|
|
+ svg.setAttribute("fill", "none")
|
|
|
+ svg.setAttribute("viewBox", "0 0 20 20")
|
|
|
+ svg.setAttribute("aria-hidden", "true")
|
|
|
+ svg.innerHTML = path
|
|
|
+ icon.appendChild(svg)
|
|
|
+ return icon
|
|
|
+}
|
|
|
+
|
|
|
+function createCopyButton(labels: CopyLabels) {
|
|
|
+ const button = document.createElement("button")
|
|
|
+ button.type = "button"
|
|
|
+ button.setAttribute("data-component", "icon-button")
|
|
|
+ button.setAttribute("data-variant", "secondary")
|
|
|
+ button.setAttribute("data-size", "normal")
|
|
|
+ button.setAttribute("data-slot", "markdown-copy-button")
|
|
|
+ button.setAttribute("aria-label", labels.copy)
|
|
|
+ button.setAttribute("title", labels.copy)
|
|
|
+ button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
|
|
+ button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
|
|
+ return button
|
|
|
+}
|
|
|
+
|
|
|
+function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
|
|
+ if (copied) {
|
|
|
+ button.setAttribute("data-copied", "true")
|
|
|
+ button.setAttribute("aria-label", labels.copied)
|
|
|
+ button.setAttribute("title", labels.copied)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ button.removeAttribute("data-copied")
|
|
|
+ button.setAttribute("aria-label", labels.copy)
|
|
|
+ button.setAttribute("title", labels.copy)
|
|
|
+}
|
|
|
+
|
|
|
+function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
|
|
|
+ const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
|
|
+
|
|
|
+ const updateLabel = (button: HTMLButtonElement) => {
|
|
|
+ const copied = button.getAttribute("data-copied") === "true"
|
|
|
+ setCopyState(button, labels, copied)
|
|
|
+ }
|
|
|
+
|
|
|
+ const ensureWrapper = (block: HTMLPreElement) => {
|
|
|
+ const parent = block.parentElement
|
|
|
+ if (!parent) return
|
|
|
+ const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
|
|
+ if (wrapped) return
|
|
|
+ const wrapper = document.createElement("div")
|
|
|
+ wrapper.setAttribute("data-component", "markdown-code")
|
|
|
+ parent.replaceChild(wrapper, block)
|
|
|
+ wrapper.appendChild(block)
|
|
|
+ wrapper.appendChild(createCopyButton(labels))
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleClick = async (event: MouseEvent) => {
|
|
|
+ const target = event.target
|
|
|
+ if (!(target instanceof Element)) return
|
|
|
+ const button = target.closest('[data-slot="markdown-copy-button"]')
|
|
|
+ if (!(button instanceof HTMLButtonElement)) return
|
|
|
+ const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
|
|
+ const content = code?.textContent ?? ""
|
|
|
+ if (!content) return
|
|
|
+ const clipboard = navigator?.clipboard
|
|
|
+ if (!clipboard) return
|
|
|
+ await clipboard.writeText(content)
|
|
|
+ setCopyState(button, labels, true)
|
|
|
+ const existing = timeouts.get(button)
|
|
|
+ if (existing) clearTimeout(existing)
|
|
|
+ const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
|
|
|
+ timeouts.set(button, timeout)
|
|
|
+ }
|
|
|
+
|
|
|
+ const blocks = Array.from(root.querySelectorAll("pre"))
|
|
|
+ for (const block of blocks) {
|
|
|
+ ensureWrapper(block)
|
|
|
+ }
|
|
|
+
|
|
|
+ const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
|
|
+ for (const button of buttons) {
|
|
|
+ if (button instanceof HTMLButtonElement) updateLabel(button)
|
|
|
+ }
|
|
|
+
|
|
|
+ root.addEventListener("click", handleClick)
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ root.removeEventListener("click", handleClick)
|
|
|
+ for (const timeout of timeouts.values()) {
|
|
|
+ clearTimeout(timeout)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function touch(key: string, value: Entry) {
|
|
|
cache.delete(key)
|
|
|
cache.set(key, value)
|
|
|
@@ -58,6 +168,8 @@ export function Markdown(
|
|
|
) {
|
|
|
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
|
|
|
const marked = useMarked()
|
|
|
+ const i18n = useI18n()
|
|
|
+ const [root, setRoot] = createSignal<HTMLDivElement>()
|
|
|
const [html] = createResource(
|
|
|
() => local.text,
|
|
|
async (markdown) => {
|
|
|
@@ -81,6 +193,19 @@ export function Markdown(
|
|
|
},
|
|
|
{ initialValue: "" },
|
|
|
)
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const container = root()
|
|
|
+ const content = html()
|
|
|
+ if (!container) return
|
|
|
+ if (!content) return
|
|
|
+ if (isServer) return
|
|
|
+ const cleanup = setupCodeCopy(container, {
|
|
|
+ copy: i18n.t("ui.message.copy"),
|
|
|
+ copied: i18n.t("ui.message.copied"),
|
|
|
+ })
|
|
|
+ onCleanup(cleanup)
|
|
|
+ })
|
|
|
return (
|
|
|
<div
|
|
|
data-component="markdown"
|
|
|
@@ -89,6 +214,7 @@ export function Markdown(
|
|
|
[local.class ?? ""]: !!local.class,
|
|
|
}}
|
|
|
innerHTML={html.latest}
|
|
|
+ ref={setRoot}
|
|
|
{...others}
|
|
|
/>
|
|
|
)
|