|
|
@@ -3,16 +3,21 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
|
|
|
import morphdom from "morphdom"
|
|
|
import { checksum } from "@opencode-ai/core/util/encode"
|
|
|
import {
|
|
|
- ComponentProps,
|
|
|
+ type Accessor,
|
|
|
+ type ComponentProps,
|
|
|
createEffect,
|
|
|
createMemo,
|
|
|
createResource,
|
|
|
createSignal,
|
|
|
createUniqueId,
|
|
|
onCleanup,
|
|
|
+ type Setter,
|
|
|
splitProps,
|
|
|
} from "solid-js"
|
|
|
-import { isServer } from "solid-js/web"
|
|
|
+import { isServer, render } from "solid-js/web"
|
|
|
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
|
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
|
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
|
import { bundledLanguages } from "shiki"
|
|
|
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
|
|
import {
|
|
|
@@ -48,11 +53,6 @@ type RenderResult = {
|
|
|
|
|
|
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
|
|
|
|
|
-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 escape(text: string) {
|
|
|
return text
|
|
|
.replace(/&/g, "&")
|
|
|
@@ -87,6 +87,14 @@ type CopyLabels = {
|
|
|
copied: string
|
|
|
}
|
|
|
|
|
|
+type CopyButtonState = {
|
|
|
+ setLabels: Setter<CopyLabels>
|
|
|
+ setCopied: Setter<boolean>
|
|
|
+ dispose: () => void
|
|
|
+}
|
|
|
+
|
|
|
+const copyButtonState = new WeakMap<HTMLElement, CopyButtonState>()
|
|
|
+
|
|
|
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
|
|
|
|
|
function codeUrl(text: string) {
|
|
|
@@ -100,45 +108,67 @@ function codeUrl(text: 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 host = document.createElement("div")
|
|
|
+ host.setAttribute("data-slot", "markdown-copy-button")
|
|
|
+
|
|
|
+ const state: Partial<CopyButtonState> = {}
|
|
|
+ const dispose = render(() => {
|
|
|
+ const [labelState, setLabels] = createSignal(labels, { equals: false })
|
|
|
+ const [copied, setCopied] = createSignal(false)
|
|
|
+ state.setLabels = setLabels
|
|
|
+ state.setCopied = setCopied
|
|
|
+ return <MarkdownCopyButton labels={labelState} copied={copied} />
|
|
|
+ }, host)
|
|
|
+ state.dispose = dispose
|
|
|
+ copyButtonState.set(host, state as CopyButtonState)
|
|
|
+ return host
|
|
|
}
|
|
|
|
|
|
-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", "small")
|
|
|
- button.setAttribute("data-slot", "markdown-copy-button")
|
|
|
- button.setAttribute("aria-label", labels.copy)
|
|
|
- button.setAttribute("data-tooltip", labels.copy)
|
|
|
- button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
|
|
- button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
|
|
- return button
|
|
|
+function MarkdownCopyButton(props: { labels: Accessor<CopyLabels>; copied: Accessor<boolean> }) {
|
|
|
+ const label = () => (props.copied() ? props.labels().copied : props.labels().copy)
|
|
|
+ return (
|
|
|
+ <TooltipV2 placement="top" value={label()}>
|
|
|
+ <IconButtonV2
|
|
|
+ type="button"
|
|
|
+ size="normal"
|
|
|
+ variant="ghost-muted"
|
|
|
+ aria-label={label()}
|
|
|
+ icon={
|
|
|
+ <>
|
|
|
+ <IconV2 name="outline-copy" data-copy-icon />
|
|
|
+ <IconV2 name="check" data-check-icon />
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </TooltipV2>
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
-function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
|
|
+function setCopyState(host: HTMLElement, labels: CopyLabels, copied: boolean) {
|
|
|
+ const state = copyButtonState.get(host)
|
|
|
+ state?.setLabels(labels)
|
|
|
+ state?.setCopied(copied)
|
|
|
if (copied) {
|
|
|
- button.setAttribute("data-copied", "true")
|
|
|
- button.setAttribute("aria-label", labels.copied)
|
|
|
- button.setAttribute("data-tooltip", labels.copied)
|
|
|
+ host.setAttribute("data-copied", "true")
|
|
|
return
|
|
|
}
|
|
|
- button.removeAttribute("data-copied")
|
|
|
- button.setAttribute("aria-label", labels.copy)
|
|
|
- button.setAttribute("data-tooltip", labels.copy)
|
|
|
+ host.removeAttribute("data-copied")
|
|
|
+}
|
|
|
+
|
|
|
+function disposeCopyButton(host: HTMLElement) {
|
|
|
+ copyButtonState.get(host)?.dispose()
|
|
|
+ copyButtonState.delete(host)
|
|
|
+}
|
|
|
+
|
|
|
+function disposeCopyButtons(root: Element) {
|
|
|
+ const hosts = [
|
|
|
+ ...(root instanceof HTMLElement && root.getAttribute("data-slot") === "markdown-copy-button" ? [root] : []),
|
|
|
+ ...Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
|
|
+ (el): el is HTMLElement => el instanceof HTMLElement,
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+ hosts.forEach(disposeCopyButton)
|
|
|
}
|
|
|
|
|
|
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
|
|
@@ -196,6 +226,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
|
|
}
|
|
|
|
|
|
for (const button of buttons.slice(1)) {
|
|
|
+ disposeCopyButton(button)
|
|
|
button.remove()
|
|
|
}
|
|
|
}
|
|
|
@@ -250,9 +281,9 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
|
|
}
|
|
|
|
|
|
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
|
- const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
|
|
+ const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
|
|
|
|
|
|
- const updateLabel = (button: HTMLButtonElement) => {
|
|
|
+ const updateLabel = (button: HTMLElement) => {
|
|
|
const labels = getLabels()
|
|
|
const copied = button.getAttribute("data-copied") === "true"
|
|
|
setCopyState(button, labels, copied)
|
|
|
@@ -263,7 +294,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
|
if (!(target instanceof Element)) return
|
|
|
|
|
|
const button = target.closest('[data-slot="markdown-copy-button"]')
|
|
|
- if (!(button instanceof HTMLButtonElement)) return
|
|
|
+ if (!(button instanceof HTMLElement)) return
|
|
|
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
|
|
const content = code?.textContent ?? ""
|
|
|
if (!content) return
|
|
|
@@ -280,7 +311,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
|
|
|
|
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
|
|
for (const button of buttons) {
|
|
|
- if (button instanceof HTMLButtonElement) updateLabel(button)
|
|
|
+ if (button instanceof HTMLElement) updateLabel(button)
|
|
|
}
|
|
|
|
|
|
root.addEventListener("click", handleClick)
|
|
|
@@ -290,6 +321,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
|
for (const timeout of timeouts.values()) {
|
|
|
clearTimeout(timeout)
|
|
|
}
|
|
|
+ disposeCopyButtons(root)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -431,6 +463,7 @@ export function Markdown(
|
|
|
if (!container) return
|
|
|
if (isServer) return
|
|
|
if (content.length === 0) {
|
|
|
+ disposeCopyButtons(container)
|
|
|
container.innerHTML = ""
|
|
|
return
|
|
|
}
|
|
|
@@ -446,9 +479,14 @@ export function Markdown(
|
|
|
activeCodeKeys.clear()
|
|
|
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
|
|
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
|
|
- while (container.children.length > content.length) container.lastElementChild?.remove()
|
|
|
+ while (container.children.length > content.length) {
|
|
|
+ const child = container.lastElementChild
|
|
|
+ if (!child) break
|
|
|
+ disposeCopyButtons(child)
|
|
|
+ child.remove()
|
|
|
+ }
|
|
|
container
|
|
|
- .querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]')
|
|
|
+ .querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
|
|
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
|
|
if (!copyCleanup)
|
|
|
copyCleanup = setupCodeCopy(container, () => ({
|
|
|
@@ -538,8 +576,8 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
|
|
morphdom(current, next, {
|
|
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
|
if (
|
|
|
- fromEl instanceof HTMLButtonElement &&
|
|
|
- toEl instanceof HTMLButtonElement &&
|
|
|
+ fromEl instanceof HTMLElement &&
|
|
|
+ toEl instanceof HTMLElement &&
|
|
|
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
|
|
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
|
|
) {
|
|
|
@@ -548,6 +586,10 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
|
|
if (fromEl.isEqualNode(toEl)) return false
|
|
|
return true
|
|
|
},
|
|
|
+ onBeforeNodeDiscarded: (node) => {
|
|
|
+ if (node instanceof Element) disposeCopyButtons(node)
|
|
|
+ return true
|
|
|
+ },
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -616,8 +658,12 @@ function updateCodeBlock(
|
|
|
unstable: block.unstable,
|
|
|
raw: block.raw,
|
|
|
})
|
|
|
- if (current) current.replaceWith(next)
|
|
|
- else container.appendChild(next)
|
|
|
+ if (current) {
|
|
|
+ disposeCopyButtons(current)
|
|
|
+ current.replaceWith(next)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ container.appendChild(next)
|
|
|
}
|
|
|
|
|
|
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|