diff --git a/package.json b/package.json index ea6c79e8..45010aba 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "electron-builder": "^26.0.12", "esbuild": "^0.25.9", "genereleaselog": "^0.0.5", + "happy-dom": "20.14.5", "less": "^4.4.1", "oxfmt": "^0.27.0", "oxlint": "^1.12.0", diff --git a/src/core/clipboard.ts b/src/core/clipboard.ts new file mode 100644 index 00000000..0fb73380 --- /dev/null +++ b/src/core/clipboard.ts @@ -0,0 +1,1124 @@ +import { Prec } from "@codemirror/state"; +import { EditorView as CodeMirrorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import type { Fragment } from "prosemirror-model"; +import type { EditorView as ProseMirrorView } from "prosemirror-view"; +import { decodeHtmlEntity, HTML_ENTITY_SYNTAX_TYPE } from "./utils/html-entities.ts"; +import { resolveImageSrc } from "./utils/image-path.ts"; +import type { ImagePasteMethod } from "./plugins/paste"; + +const CELL_BORDER = "1px solid #cccccc"; +const CLIPBOARD_TREEWALKER_SHOW_TEXT = typeof NodeFilter === "undefined" ? 4 : NodeFilter.SHOW_TEXT; + +export const RENDERED_CLIPBOARD_NODE_TYPES = new Set([ + "table", + "image", + "code_block", + "math_block", + "html_block", +]); + +export function hasRenderedClipboardNode(fragment: Fragment): boolean { + let found = false; + fragment.descendants((node) => { + if (RENDERED_CLIPBOARD_NODE_TYPES.has(node.type.name)) found = true; + return !found; + }); + return found; +} + +export interface ClipboardBuildOptions { + sourceView?: boolean; + fontFamily?: string; + codeFontFamily?: string; + imageMode?: ImagePasteMethod; +} + +export interface ClipboardPayload { + plain: string; + html: string; + richRequired?: boolean; +} + +export type ClipboardWriteStatus = "rich" | "plain" | "failed"; + +export interface ClipboardWriteResult { + status: ClipboardWriteStatus; +} + +export interface CodeClipboardOptions { + fontFamily?: string; + codeFontFamily?: string; + codeStyle?: string; + blockStyle?: string; +} + +export function canDeleteAfterClipboardWrite( + payload: ClipboardPayload, + result: ClipboardWriteResult +): boolean { + const richRequired = payload.richRequired ?? !!payload.html; + return result.status !== "failed" && (!richRequired || result.status === "rich"); +} + +function cleanFontFamily(value: string): string { + return value.trim().replace(/;+\s*$/, ""); +} + +export function getClipboardFontFamilies( + element: HTMLElement, + codeElement?: HTMLElement +): Pick { + if (typeof getComputedStyle !== "function") return {}; + + try { + const style = getComputedStyle(element); + const codeStyle = codeElement ? getComputedStyle(codeElement) : style; + const fontFamily = + cleanFontFamily(style.getPropertyValue("--milkup-font-default")) || + cleanFontFamily(style.fontFamily); + const codeFontFamily = + cleanFontFamily(codeStyle.getPropertyValue("--milkup-font-code")) || + (codeElement ? cleanFontFamily(codeStyle.fontFamily) : ""); + return { + ...(fontFamily ? { fontFamily } : {}), + ...(codeFontFamily ? { codeFontFamily } : {}), + }; + } catch { + return {}; + } +} + +export function getCodeClipboardOptions(view: CodeMirrorView): CodeClipboardOptions { + const options: CodeClipboardOptions = getClipboardFontFamilies(view.dom, view.contentDOM); + try { + if (typeof getComputedStyle === "function") { + options.codeStyle = getComputedStyle(view.contentDOM).cssText; + const block = view.dom.closest(".milkup-code-block"); + if (block) options.blockStyle = getComputedStyle(block).cssText; + } + } catch { + // Font information is still useful when computed styles are unavailable. + } + return options; +} + +export function isClipboardElementHidden(element: Element): boolean { + let current: Element | null = element; + while (current) { + if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") + return true; + const inlineStyle = (current as HTMLElement).style; + if (inlineStyle.display === "none" || inlineStyle.visibility === "hidden") return true; + + if (typeof getComputedStyle === "function") { + try { + const computed = getComputedStyle(current); + if (computed.display === "none" || computed.visibility === "hidden") return true; + } catch { + // Inline visibility is still usable when computed styles are unavailable. + } + } + current = current.parentElement; + } + return false; +} + +export function isClipboardElementVisible(element: Element): boolean { + return !isClipboardElementHidden(element); +} + +export function getRenderedSelectionRoot( + view: ProseMirrorView, + imageMode?: ImagePasteMethod +): HTMLElement | null { + const document = view.dom.ownerDocument; + if (view.state?.selection?.empty) return null; + + const range = getSelectionRange(view, document); + if (!range || !view.dom.contains(range.commonAncestorContainer)) return null; + + const sourceClone = cloneWithInlineStyles(view.dom, imageMode); + const start = getNodeAtPath(sourceClone, getNodePath(view.dom, range.startContainer)); + const end = getNodeAtPath(sourceClone, getNodePath(view.dom, range.endContainer)); + if (!start || !end) return null; + + try { + const clonedRange = document.createRange(); + clonedRange.setStart(start, range.startOffset); + clonedRange.setEnd(end, range.endOffset); + const host = document.createElement("div"); + host.appendChild(clonedRange.extractContents()); + cleanRenderedClipboardDom(host); + copyProseMirrorSliceMarker(host, view); + return host; + } catch { + return null; + } +} + +function getSelectionRange(view: ProseMirrorView, document: Document): Range | null { + try { + const start = view.domAtPos(view.state.selection.from); + const end = view.domAtPos(view.state.selection.to); + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + if (!range.collapsed && view.dom.contains(range.commonAncestorContainer)) return range; + } catch { + // The browser selection below is the fallback for DOM positions PM cannot map. + } + + const selection = document.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (!range.collapsed && view.dom.contains(range.commonAncestorContainer)) return range; + } + return null; +} + +function copyProseMirrorSliceMarker(host: HTMLElement, view: ProseMirrorView): void { + if (typeof view.serializeForClipboard !== "function" || !view.state?.selection) return; + + try { + const { dom } = view.serializeForClipboard(view.state.selection.content()); + const marker = + dom.firstElementChild?.getAttribute("data-pm-slice") || + dom.querySelector("[data-pm-slice]")?.getAttribute("data-pm-slice"); + if (!marker) return; + host.setAttribute("data-pm-slice", marker); + if (host.firstElementChild) host.firstElementChild.setAttribute("data-pm-slice", marker); + } catch { + // External clipboard consumers do not need the ProseMirror marker. + } +} + +export function getRenderedNodeRoot( + view: ProseMirrorView, + pos: number, + imageMode?: ImagePasteMethod +): HTMLElement | null { + const node = view.nodeDOM(pos); + if (!node || node.nodeType !== 1) return null; + const clonedNode = cloneWithInlineStyles(node as HTMLElement, imageMode); + const document = view.dom?.ownerDocument || node.ownerDocument; + if (!document) return null; + const root = document.createElement("div"); + root.appendChild(clonedNode); + cleanRenderedClipboardDom(root); + root.setAttribute("data-pm-slice", "0 0 []"); + if (root.firstElementChild) root.firstElementChild.setAttribute("data-pm-slice", "0 0 []"); + return root; +} + +function cloneWithInlineStyles(source: HTMLElement, imageMode?: ImagePasteMethod): HTMLElement { + const clone = source.cloneNode(true) as HTMLElement; + copyComputedStyles(source, clone, imageMode); + return clone; +} + +function getClipboardImageSource(image: HTMLImageElement): string { + return image.currentSrc || image.src || image.getAttribute("src") || ""; +} + +function isRemoteClipboardImageSource(src: string): boolean { + return /^(?:https?:|data:)/i.test(src); +} + +function resolveLocalClipboardImageSource(src: string): string { + try { + return resolveImageSrc(src) || src; + } catch { + return src; + } +} + +/** + * Resolve an image for the clipboard without changing the document source. + * `local` may use a cached local Data URL, `base64` always tries to produce + * one, and `remote` never replaces a remote URL with embedded image data. + */ +export function resolveClipboardImageSource( + image: HTMLImageElement, + mode: ImagePasteMethod +): string | null { + const source = getClipboardImageSource(image); + if (!source) return null; + + const cached = image.getAttribute("data-clipboard-src"); + switch (mode) { + case "remote": + return isRemoteClipboardImageSource(source) + ? source + : resolveLocalClipboardImageSource(source); + case "local": + return cached || resolveLocalClipboardImageSource(source); + case "base64": + return cached || getLoadedImageDataUrl(image) || resolveLocalClipboardImageSource(source); + } +} + +function copyClipboardImageSource( + source: Element, + target: Element, + imageMode?: ImagePasteMethod +): void { + if (!imageMode || source.tagName.toLowerCase() !== "img") return; + const resolved = resolveClipboardImageSource(source as HTMLImageElement, imageMode); + if (resolved) target.setAttribute("src", resolved); + target.removeAttribute("data-clipboard-src"); +} + +// Text, tables, and code reflow in the target; media keeps intrinsic dimensions. +const CLIPBOARD_LAYOUT_PROPERTIES = new Set([ + "width", + "min-width", + "max-width", + "height", + "min-height", + "max-height", + "display", + "position", + "top", + "right", + "bottom", + "left", + "inset", + "overflow", + "overflow-x", + "overflow-y", + "white-space", + "word-break", + "overflow-wrap", + "flex", + "flex-basis", + "flex-grow", + "flex-shrink", + "flex-direction", + "flex-wrap", + "grid-template-columns", + "grid-template-rows", + "transform", +]); + +// Only intrinsic media/code formatting survives; editor viewport geometry does not. +const CLIPBOARD_LAYOUT_TAGS = new Set(["img", "svg", "canvas", "pre", "code"]); +const CLIPBOARD_LAYOUT_CONTAINERS = + ".milkup-code-block-editor, .milkup-html-block-editor, .milkup-image-block, .milkup-mermaid-preview"; +const CLIPBOARD_FLOW_WRAP_STYLES = { + "white-space": "normal", + "word-break": "normal", + "overflow-wrap": "break-word", +} as const; +const CLIPBOARD_VIEWPORT_GEOMETRY_ATTRIBUTES = ["width", "height", "cellwidth", "cellheight"]; +const CLIPBOARD_CODE_TAGS = new Set(["pre", "code"]); +const CLIPBOARD_CODE_WRAP_STYLES = { + "white-space": "pre-wrap", + "word-break": "normal", + "overflow-wrap": "break-word", +} as const; + +function shouldPreserveClipboardLayout(source: Element): boolean { + const tag = source.tagName.toLowerCase(); + return CLIPBOARD_LAYOUT_TAGS.has(tag) || !!source.closest(CLIPBOARD_LAYOUT_CONTAINERS); +} + +function copyElementTree( + source: Element, + target: Element, + visit: (source: Element, target: Element) => void +): void { + visit(source, target); + const sourceChildren = Array.from(source.children); + const targetChildren = Array.from(target.children); + for (let index = 0; index < sourceChildren.length; index += 1) { + copyElementTree(sourceChildren[index], targetChildren[index], visit); + } +} + +function copyComputedStyles(source: Element, target: Element, imageMode?: ImagePasteMethod): void { + copyElementTree(source, target, (sourceElement, targetElement) => { + if (isClipboardElementHidden(sourceElement)) { + targetElement.setAttribute("data-clipboard-hidden", "true"); + } + copyClipboardImageSource(sourceElement, targetElement, imageMode); + const preserveLayout = shouldPreserveClipboardLayout(sourceElement); + if (!preserveLayout) { + for (const property of CLIPBOARD_LAYOUT_PROPERTIES) { + (targetElement as HTMLElement).style.removeProperty(property); + } + } + // Flow nodes stay semantic; copying Chromium's full computed declaration set bloats CF_HTML. + if (preserveLayout && typeof getComputedStyle === "function") { + const computed = getComputedStyle(sourceElement); + for (const property of Array.from(computed)) { + const value = computed.getPropertyValue(property); + if (value) (targetElement as HTMLElement).style.setProperty(property, value); + } + } + }); +} + +function getNodePath(root: Node, target: Node): number[] | null { + const path: number[] = []; + let current: Node | null = target; + while (current && current !== root) { + const parent: Node | null = current.parentNode; + if (!parent) return null; + path.unshift(Array.prototype.indexOf.call(parent.childNodes, current)); + current = parent; + } + return current === root ? path : null; +} + +function getNodeAtPath(root: Node, path: number[] | null): Node | null { + if (!path) return null; + let current: Node = root; + for (const index of path) { + current = current.childNodes[index]; + if (!current) return null; + } + return current; +} + +function normalizeClipboardCodeLayout(element: Element): void { + for (const attribute of CLIPBOARD_VIEWPORT_GEOMETRY_ATTRIBUTES) { + element.removeAttribute(attribute); + } + for (const property of CLIPBOARD_LAYOUT_PROPERTIES) { + (element as HTMLElement).style.removeProperty(property); + } + for (const [property, value] of Object.entries(CLIPBOARD_CODE_WRAP_STYLES)) { + (element as HTMLElement).style.setProperty(property, value); + } +} + +function sanitizeClipboardFlowDom(root: HTMLElement): void { + const elements: Element[] = [root, ...Array.from(root.querySelectorAll("*"))]; + for (const element of elements) { + if (isClipboardElementHidden(element)) { + element.remove(); + continue; + } + if (shouldPreserveClipboardLayout(element)) { + if (CLIPBOARD_CODE_TAGS.has(element.tagName.toLowerCase())) { + normalizeClipboardCodeLayout(element); + } + continue; + } + for (const attribute of CLIPBOARD_VIEWPORT_GEOMETRY_ATTRIBUTES) { + element.removeAttribute(attribute); + } + for (const property of CLIPBOARD_LAYOUT_PROPERTIES) { + (element as HTMLElement).style.removeProperty(property); + } + for (const [property, value] of Object.entries(CLIPBOARD_FLOW_WRAP_STYLES)) { + (element as HTMLElement).style.setProperty(property, value); + } + } +} + +function cleanRenderedClipboardDom(root: HTMLElement): void { + const controls = [ + ".milkup-code-block-header", + ".milkup-code-block-copy-btn", + ".milkup-html-block-header", + ".milkup-list-marker", + ".cm-gutters", + ".cm-lineNumbers", + ].join(","); + + for (const element of Array.from(root.querySelectorAll("*"))) { + if (element.matches(controls)) { + element.remove(); + continue; + } + if ( + element.hasAttribute("data-clipboard-hidden") || + element.hasAttribute("hidden") || + element.getAttribute("aria-hidden") === "true" || + element.style.display === "none" || + element.style.visibility === "hidden" + ) { + element.remove(); + continue; + } + element.removeAttribute("contenteditable"); + element.removeAttribute("data-clipboard-pending"); + } + + for (const checkbox of Array.from(root.querySelectorAll(".milkup-task-checkbox"))) { + const input = root.ownerDocument.createElement("input"); + input.type = "checkbox"; + input.checked = checkbox.getAttribute("aria-checked") === "true"; + input.disabled = true; + input.style.cssText = checkbox.getAttribute("style") || ""; + checkbox.replaceWith(input); + } + + for (const editor of Array.from( + root.querySelectorAll(".milkup-code-block-editor, .milkup-html-block-editor") + )) { + if (editor.style.display === "none") { + editor.remove(); + continue; + } + const content = editor.querySelector(".cm-content"); + if (!content) { + editor.remove(); + continue; + } + const text = Array.from(content.querySelectorAll(".cm-line")) + .map((line) => line.textContent || "") + .join("\n"); + if (editor.classList.contains("milkup-html-block-editor")) { + try { + const Parser = + root.ownerDocument.defaultView?.DOMParser ?? + (typeof DOMParser !== "undefined" ? DOMParser : null); + if (Parser) { + const parsed = new Parser().parseFromString(text, "text/html"); + const rendered = root.ownerDocument.createElement("div"); + while (parsed.body.firstChild) rendered.appendChild(parsed.body.firstChild); + editor.replaceWith(rendered); + continue; + } + } catch { + // Fall through to a source-code fragment if the browser cannot parse it. + } + } + + const pre = root.ownerDocument.createElement("pre"); + const code = root.ownerDocument.createElement("code"); + code.textContent = text; + pre.style.cssText = content.style.cssText; + code.style.cssText = content.style.cssText; + pre.appendChild(code); + editor.replaceWith(pre); + } + + for (const preview of Array.from(root.querySelectorAll(".milkup-image-preview"))) { + const content = preview.querySelector("a, img"); + if (content) preview.replaceWith(content); + } + + for (const input of Array.from( + root.querySelectorAll(".milkup-image-source-input") + )) { + const text = root.ownerDocument.createElement("span"); + text.textContent = input.value; + input.replaceWith(text); + } +} + +export function semanticizeClipboardDom( + dom: HTMLElement, + options: ClipboardBuildOptions = {} +): HTMLElement | null { + if (options.sourceView) return null; + + const root = dom.cloneNode(true) as HTMLElement; + const document = root.ownerDocument; + const headingsWithMarker = new Set(); + + for (const marker of Array.from(root.querySelectorAll("span.milkup-syntax"))) { + const syntaxType = marker.getAttribute("data-syntax-type"); + if (syntaxType === "heading") { + const heading = marker.closest("h1,h2,h3,h4,h5,h6"); + if (heading) headingsWithMarker.add(heading); + } + + if (syntaxType === HTML_ENTITY_SYNTAX_TYPE) { + const decoded = decodeHtmlEntity(marker.textContent ?? "") ?? marker.textContent ?? ""; + const children = Array.from(marker.childNodes); + const firstText = document + .createTreeWalker(marker, CLIPBOARD_TREEWALKER_SHOW_TEXT) + .nextNode(); + if (firstText) firstText.textContent = decoded; + marker.replaceWith(...(children.length > 0 ? children : [document.createTextNode(decoded)])); + } else { + // 语法标记自身可能包着 strong/em 等语义节点;这些节点只承载 Markdown 符号。 + marker.remove(); + } + } + + for (const heading of headingsWithMarker) { + const walker = document.createTreeWalker(heading, CLIPBOARD_TREEWALKER_SHOW_TEXT); + const firstText = walker.nextNode(); + if (firstText?.textContent?.startsWith(" ")) { + firstText.textContent = firstText.textContent.slice(1); + } + } + + sanitizeClipboardFlowDom(root); + return root; +} + +export function buildClipboardPayload( + plain: string, + selectionRoot: HTMLElement | null, + options: ClipboardBuildOptions = {} +): ClipboardPayload { + const richRequired = !options.sourceView; + const root = selectionRoot ? semanticizeClipboardDom(selectionRoot, options) : null; + if (!root) return { plain, html: "", richRequired }; + + stripUnsafeContent(root); + applyTableBorders(root); + applyClipboardFonts(root, options); + applyClipboardImages(root, options.imageMode); + applyClipboardPngFallback(root); + return { plain, html: root.outerHTML, richRequired }; +} + +export function buildCodeClipboardPayload( + text: string, + options: CodeClipboardOptions = {} +): ClipboardPayload { + const root = document.createElement("div"); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = text; + if (options.blockStyle) pre.style.cssText = options.blockStyle; + if (options.codeStyle) { + if (!options.blockStyle) pre.style.cssText = options.codeStyle; + code.style.cssText = options.codeStyle; + } + if (options.fontFamily && !options.blockStyle) pre.style.fontFamily = options.fontFamily; + if (options.codeFontFamily) { + if (!options.blockStyle) pre.style.fontFamily = options.codeFontFamily; + code.style.fontFamily = options.codeFontFamily; + } + pre.appendChild(code); + root.appendChild(pre); + return buildClipboardPayload(text, root, options); +} + +export function writeClipboardEvent( + event: ClipboardEvent, + payload: ClipboardPayload +): ClipboardWriteResult { + const data = event.clipboardData; + if (!data) return { status: "failed" }; + + let plainWritten = false; + try { + data.clearData(); + data.setData("text/plain", payload.plain); + plainWritten = true; + if (payload.html) data.setData("text/html", payload.html); + event.preventDefault(); + return { status: payload.html ? "rich" : "plain" }; + } catch (error) { + console.error("写入剪贴板失败", error); + if (!plainWritten) return { status: "failed" }; + + // Keep the successful plain-text write usable, but report that the + // requested rich representation was not available. + event.preventDefault(); + return { status: "plain" }; + } +} + +type ElectronClipboardApi = { + writeToClipboard?: (payload: { text: string; html: string }) => Promise; + writeTextToClipboard?: (text: string) => Promise; +}; + +function getBrowserClipboard(): Clipboard | null { + if (typeof navigator === "undefined" || !navigator.clipboard) return null; + return navigator.clipboard; +} + +function getElectronClipboardApi(): ElectronClipboardApi | null { + if (typeof window === "undefined") return null; + return window.electronAPI ?? null; +} + +async function writeBrowserRichClipboard(payload: ClipboardPayload): Promise { + const clipboard = getBrowserClipboard(); + if ( + !payload.html || + !clipboard || + typeof clipboard.write !== "function" || + typeof ClipboardItem === "undefined" + ) { + return false; + } + + try { + await clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([payload.plain], { type: "text/plain" }), + "text/html": new Blob([payload.html], { type: "text/html" }), + }), + ]); + return true; + } catch { + return false; + } +} + +async function writeElectronRichClipboard(payload: ClipboardPayload): Promise { + const api = getElectronClipboardApi(); + if (!payload.html || !api?.writeToClipboard) return false; + + try { + return (await api.writeToClipboard({ text: payload.plain, html: payload.html })) === true; + } catch { + return false; + } +} + +async function writeBrowserPlainClipboard(text: string): Promise { + const clipboard = getBrowserClipboard(); + if (!clipboard || typeof clipboard.writeText !== "function") return false; + + try { + await clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +async function writeElectronPlainClipboard(text: string): Promise { + const api = getElectronClipboardApi(); + if (!api?.writeTextToClipboard) return false; + + try { + return (await api.writeTextToClipboard(text)) === true; + } catch { + return false; + } +} + +export async function writeClipboardPayload( + payload: ClipboardPayload +): Promise { + if (payload.html) { + if (await writeBrowserRichClipboard(payload)) return { status: "rich" }; + if (await writeElectronRichClipboard(payload)) return { status: "rich" }; + } + + if (await writeBrowserPlainClipboard(payload.plain)) return { status: "plain" }; + if (await writeElectronPlainClipboard(payload.plain)) return { status: "plain" }; + return { status: "failed" }; +} + +type CodeClipboardRange = { from: number; to: number }; +type CodeClipboardPayloadBuilder = (text: string, view: CodeMirrorView) => ClipboardPayload; + +export function createCodeClipboardExtension( + isReadOnly: () => boolean = () => false, + buildPayload: CodeClipboardPayloadBuilder = (text, view) => + buildCodeClipboardPayload(text, getCodeClipboardOptions(view)) +): Extension { + return Prec.highest( + CodeMirrorView.domEventHandlers({ + copy: (event, view) => + writeCodeClipboard(event as ClipboardEvent, view, isReadOnly, buildPayload), + cut: (event, view) => + writeCodeClipboard(event as ClipboardEvent, view, isReadOnly, buildPayload), + }) + ); +} + +export function getCodeClipboardSelection(view: CodeMirrorView): { + text: string; + ranges: CodeClipboardRange[]; +} { + const selectedRanges = view.state.selection.ranges.filter((range) => !range.empty); + if (selectedRanges.length > 0) { + return { + text: selectedRanges + .map((range) => view.state.sliceDoc(range.from, range.to)) + .join(view.state.lineBreak), + ranges: selectedRanges.map((range) => ({ from: range.from, to: range.to })), + }; + } + + const ranges: CodeClipboardRange[] = []; + const lines: string[] = []; + let lastLineNumber = -1; + for (const range of view.state.selection.ranges) { + const line = view.state.doc.lineAt(range.from); + if (line.number <= lastLineNumber) continue; + lastLineNumber = line.number; + ranges.push({ from: line.from, to: Math.min(view.state.doc.length, line.to + 1) }); + lines.push(line.text); + } + + return { text: lines.join(view.state.lineBreak), ranges }; +} + +function writeCodeClipboard( + event: ClipboardEvent, + view: CodeMirrorView, + isReadOnly: () => boolean, + buildPayload: CodeClipboardPayloadBuilder +): boolean { + const selection = document.getSelection(); + const anchor = selection?.anchorNode; + const focus = selection?.focusNode; + const selectionIsInside = + !!anchor && !!focus && view.contentDOM.contains(anchor) && view.contentDOM.contains(focus); + + if (!selectionIsInside) return false; + + // CodeMirror 的默认 cut handler 仍会删除内容;只读时必须由这里拦截。 + if (event.type === "cut" && isReadOnly()) { + event.preventDefault(); + return true; + } + + const { text, ranges } = getCodeClipboardSelection(view); + if (!event.clipboardData || ranges.length === 0) { + if (event.type === "cut") { + event.preventDefault(); + return true; + } + return false; + } + + const payload = buildPayload(text, view); + const result = writeClipboardEvent(event, payload); + if (event.type === "cut" && !canDeleteAfterClipboardWrite(payload, result)) { + event.preventDefault(); + return true; + } + if (result.status === "failed") return false; + + if (event.type === "cut") { + view.dispatch({ + changes: ranges, + scrollIntoView: true, + userEvent: "delete.cut", + }); + } + return true; +} + +function stripUnsafeContent(root: HTMLElement): void { + root + .querySelectorAll("script,style,iframe,object,embed,link,meta") + .forEach((node) => node.remove()); + + for (const element of root.querySelectorAll("*")) { + element.removeAttribute("contenteditable"); + for (const attribute of Array.from(element.attributes)) { + if (/^on/i.test(attribute.name)) { + element.removeAttribute(attribute.name); + continue; + } + if (["href", "src", "action"].includes(attribute.name)) { + const value = attribute.value.trim(); + if ( + /^(javascript|vbscript):/i.test(value) || + (/^data:/i.test(value) && !/^data:image\//i.test(value)) + ) { + element.removeAttribute(attribute.name); + } + } + } + } +} + +function applyClipboardFonts(root: HTMLElement, options: ClipboardBuildOptions): void { + const { fontFamily, codeFontFamily } = options; + if (fontFamily) root.style.fontFamily = fontFamily; + + for (const element of root.querySelectorAll("*")) { + if (element.style.fontFamily && !element.style.fontFamily.includes("var(")) continue; + const tag = element.tagName.toLowerCase(); + const family = ["pre", "code", "kbd", "samp"].includes(tag) ? codeFontFamily : fontFamily; + if (family) element.style.fontFamily = family; + } +} + +function applyClipboardImages(root: HTMLElement, mode: ImagePasteMethod | undefined): void { + if (!mode) return; + + for (const image of Array.from(root.querySelectorAll("img"))) { + const resolved = resolveClipboardImageSource(image, mode); + if (resolved) image.setAttribute("src", resolved); + image.removeAttribute("data-clipboard-src"); + } +} + +function getLoadedImageDataUrl(image: HTMLImageElement): string | null { + if (!image.complete || !image.naturalWidth || !image.naturalHeight) return null; + + try { + const canvas = image.ownerDocument.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(image, 0, 0); + return canvas.toDataURL(); + } catch { + return null; + } +} + +export async function cacheClipboardImage(image: HTMLImageElement): Promise { + const dataUrl = + getLoadedImageDataUrl(image) || (await fetchImageAsDataUrl(image.currentSrc || image.src)); + if (dataUrl && image.isConnected) image.setAttribute("data-clipboard-src", dataUrl); + return dataUrl; +} + +async function fetchImageAsDataUrl(src: string): Promise { + if (!src || src.startsWith("data:")) return null; + try { + const response = await fetch(src); + if (!response.ok && response.status !== 0) return null; + return await blobToDataUrl(await response.blob()); + } catch { + return null; + } +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = () => resolve(""); + reader.readAsDataURL(blob); + }); +} + +export async function cacheClipboardPng(element: Element): Promise { + element.removeAttribute("data-clipboard-png"); + const dataUrl = await rasterizeElement(element); + if (dataUrl && element.isConnected) element.setAttribute("data-clipboard-png", dataUrl); + return dataUrl; +} + +export type ClipboardFallbackRasterizer = (element: Element) => Promise; + +export function requestClipboardPngFallback( + element: Element, + rasterize: ClipboardFallbackRasterizer = cacheClipboardPng +): void { + if ( + !isClipboardElementVisible(element) || + element.hasAttribute("data-clipboard-png") || + element.hasAttribute("data-clipboard-pending") + ) { + return; + } + element.setAttribute("data-clipboard-pending", "true"); + void Promise.resolve() + .then(() => rasterize(element)) + .catch(() => null) + .finally(() => element.removeAttribute("data-clipboard-pending")); +} + +export function refreshClipboardFallbacks( + root: ParentNode, + rasterize: ClipboardFallbackRasterizer = cacheClipboardPng +): void { + const targets = root.querySelectorAll(".math-preview, .milkup-mermaid-preview svg"); + for (const target of Array.from(targets)) { + requestClipboardPngFallback(target, rasterize); + } +} + +function inlineRuntimeStylesForRaster(source: Element, target: Element): void { + if (typeof getComputedStyle !== "function") return; + + copyElementTree(source, target, (sourceElement, targetElement) => { + try { + const computed = getComputedStyle(sourceElement); + for (let index = 0; index < computed.length; index += 1) { + const property = computed.item(index); + const value = computed.getPropertyValue(property); + if (value) (targetElement as HTMLElement).style.setProperty(property, value); + } + } catch { + // A best-effort inline style is still useful when computed styles are partial. + } + }); +} + +function absolutizeFontFaceCssUrls(css: string, baseUri: string): string { + return css.replace(/url\((['"]?)([^)'"\s]+)\1\)/gi, (match, quote, value) => { + if (/^(?:data:|blob:|https?:|file:)/i.test(value)) return match; + try { + return `url(${quote}${new URL(value, baseUri).href}${quote})`; + } catch { + return match; + } + }); +} + +function getRuntimeFontFaceCss(document: Document): string { + const rules: string[] = []; + const sheets = document.styleSheets; + + if (sheets) { + for (let sheetIndex = 0; sheetIndex < sheets.length; sheetIndex += 1) { + try { + const sheet = sheets.item(sheetIndex); + if (!sheet) continue; + let sheetRules = 0; + for (const rule of Array.from(sheet.cssRules)) { + const cssText = rule.cssText || ""; + if (/^@font-face\b/i.test(cssText.trim())) { + rules.push(absolutizeFontFaceCssUrls(cssText, document.baseURI)); + sheetRules += 1; + } + } + if (sheetRules === 0) { + const styleText = sheet.ownerNode?.textContent || ""; + rules.push( + ...(styleText.match(/@font-face\s*\{[\s\S]*?\}/gi) || []).map((rule) => + absolutizeFontFaceCssUrls(rule, document.baseURI) + ) + ); + } + } catch { + // Cross-origin stylesheets are intentionally left to the browser fallback. + } + } + } + if (rules.length === 0) { + for (const style of Array.from(document.querySelectorAll("style"))) { + rules.push( + ...(style.textContent?.match(/@font-face\s*\{[\s\S]*?\}/gi) || []).map((rule) => + absolutizeFontFaceCssUrls(rule, document.baseURI) + ) + ); + } + } + return rules.join("\n"); +} + +export function buildSelfContainedMarkup(element: Element): { markup: string; isSvg: boolean } { + const isSvg = element.tagName.toLowerCase() === "svg"; + const clone = element.cloneNode(true) as Element; + inlineRuntimeStylesForRaster(element, clone); + removeClipboardPngAttributes(clone); + + const fontFaceCss = getRuntimeFontFaceCss(element.ownerDocument); + if (fontFaceCss) { + const style = isSvg + ? element.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "style") + : element.ownerDocument.createElement("style"); + style.textContent = fontFaceCss; + clone.insertBefore(style, clone.firstChild); + } + + return { markup: clone.outerHTML, isSvg }; +} + +async function rasterizeElement(element: Element): Promise { + if ( + typeof Image === "undefined" || + typeof URL === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + return null; + } + + let url: string | undefined; + try { + const width = Math.ceil(element.getBoundingClientRect?.().width || 0); + const height = Math.ceil(element.getBoundingClientRect?.().height || 0); + if (width <= 0 || height <= 0) return null; + + const originalMarkup = element.outerHTML; + const { markup, isSvg } = buildSelfContainedMarkup(element); + const svgMarkup = + isSvg && !/\sxmlns=/.test(markup) + ? markup.replace(/^
${markup}
`; + const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + url = objectUrl; + + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("PNG fallback image failed to load")); + image.src = objectUrl; + }); + + const canvas = element.ownerDocument.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(image, 0, 0, width, height); + if (element.outerHTML !== originalMarkup) return null; + return canvas.toDataURL("image/png"); + } catch { + return null; + } finally { + if (url) URL.revokeObjectURL(url); + } +} + +function removeClipboardPngAttributes(element: Element): void { + for (const node of [element, ...Array.from(element.querySelectorAll("*"))]) { + node.removeAttribute("data-clipboard-png"); + } +} + +function applyClipboardPngFallback(root: HTMLElement): void { + const elements: Element[] = []; + if (root.hasAttribute("data-clipboard-png")) elements.push(root); + elements.push(...Array.from(root.querySelectorAll("[data-clipboard-png]"))); + + for (const element of elements) { + const png = element.getAttribute("data-clipboard-png"); + if (!png) continue; + + const document = element.ownerDocument; + const image = document.createElement("img"); + image.src = png; + image.alt = element.getAttribute("aria-label") || element.getAttribute("alt") || ""; + image.style.cssText = element.getAttribute("style") || ""; + image.className = "milkup-clipboard-png-fallback"; + image.setAttribute("data-clipboard-fallback-image", "true"); + image.setAttribute("aria-hidden", "true"); + + if (element.tagName.toLowerCase() === "svg") { + const picture = document.createElement("picture"); + const source = document.createElement("source"); + const primary = element.cloneNode(true) as Element; + removeClipboardPngAttributes(primary); + source.type = "image/svg+xml"; + source.srcset = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(primary.outerHTML)}`; + picture.append(source, image); + element.replaceWith(picture); + continue; + } + + const wrapper = document.createElement("div"); + wrapper.className = "milkup-clipboard-fallback"; + wrapper.setAttribute("data-clipboard-fallback", "png"); + const primary = element.cloneNode(true) as Element; + removeClipboardPngAttributes(primary); + image.style.display = "none"; + wrapper.append(primary, image); + element.replaceWith(wrapper); + } +} + +/** 表格是本次剪贴板验收明确需要的唯一额外样式。 */ +function applyTableBorders(root: HTMLElement): void { + for (const table of root.querySelectorAll("table")) { + if (!table.getAttribute("border")) table.setAttribute("border", "1"); + if (!table.style.borderCollapse) table.style.borderCollapse = "collapse"; + } + + for (const cell of root.querySelectorAll("th,td")) { + if (!cell.style.border) cell.style.border = CELL_BORDER; + } +} diff --git a/src/core/editor.ts b/src/core/editor.ts index 2d73d021..e6748256 100644 --- a/src/core/editor.ts +++ b/src/core/editor.ts @@ -17,6 +17,18 @@ import { keymap } from "prosemirror-keymap"; import "katex/dist/katex.min.css"; import { milkupSchema } from "./schema"; +import { + buildClipboardPayload, + canDeleteAfterClipboardWrite, + getClipboardFontFamilies, + getRenderedNodeRoot, + getRenderedSelectionRoot, + hasRenderedClipboardNode, + refreshClipboardFallbacks, + writeClipboardEvent, + writeClipboardPayload, + type ClipboardPayload, +} from "./clipboard"; import { parseMarkdown, MarkdownParser } from "./parser"; import { serializeMarkdown, MarkdownSerializer } from "./serializer"; import { createInstantRenderPlugin } from "./plugins/instant-render"; @@ -32,6 +44,7 @@ import { createPastePlugin, containsMarkdownSyntax, fileToBase64, + getImagePasteMethod, parseMarkdownPasteSlice, saveImageLocally, ImagePasteMethod, @@ -119,6 +132,11 @@ const defaultConfig: MilkupConfig = { sourceView: false, }; +// Flow text uses ProseMirror's semantic clipboard DOM; live DOM is for bounded nodes. +function shouldUseRenderedClipboardDom(view: EditorView): boolean { + return hasRenderedClipboardNode(view.state.selection.content().content); +} + /** * Milkup 编辑器类 */ @@ -176,7 +194,8 @@ export class MilkupEditor implements IMilkupEditor { return parseMarkdownPasteSlice(text, this.parser) || Slice.empty; }, nodeViews: { - code_block: createCodeBlockNodeView, + code_block: (node, view, getPos) => + createCodeBlockNodeView(node, view, getPos, () => this.config.readonly === true), math_block: createMathBlockNodeView, html_block: createHtmlBlockNodeView, image: createImageNodeView, @@ -194,6 +213,7 @@ export class MilkupEditor implements IMilkupEditor { handleDOMEvents: { contextmenu: (view, event) => this.handleContextMenu(view, event), copy: (view, event) => this.handleCopy(view, event), + cut: (view, event) => this.handleCut(view, event), }, }); @@ -411,6 +431,11 @@ export class MilkupEditor implements IMilkupEditor { this.view.focus(); } + /** Retry async PNG fallbacks after a previously hidden editor becomes visible. */ + refreshClipboardFallbacks(): void { + refreshClipboardFallbacks(this.view.dom); + } + /** * 处理编辑器点击事件 * 用于处理点击空白区域时的聚焦 @@ -788,18 +813,139 @@ export class MilkupEditor implements IMilkupEditor { this.view.dispatch(tr); } - /** - * 复制时根据当前选区决定表格内容的输出格式 - */ - private handleCopy(_view: EditorView, event: ClipboardEvent): boolean { - if (this.view.state.selection.empty) return false; + /** 复制时保留当前表格选区的纯文本规则,并同时写入富文本。 */ + private handleCopy(view: EditorView, event: ClipboardEvent): boolean { + if ( + this.isClipboardEventFromCodeBlock(view, event) || + this.isClipboardEventFromHtmlBlockEditor(view, event) || + view.state.selection.empty + ) + return false; - const text = this.serializeSelectionForClipboard(); - event.clipboardData?.setData("text/plain", text); - event.preventDefault(); + const plain = this.serializeSelectionForClipboard(); + return this.writeNativeClipboard(view, event, plain, false); + } + + /** 剪切只在双格式载荷写入成功后修改文档。 */ + private handleCut(view: EditorView, event: ClipboardEvent): boolean { + if ( + this.isClipboardEventFromCodeBlock(view, event) || + this.isClipboardEventFromHtmlBlockEditor(view, event) || + !view.editable || + view.state.selection.empty + ) { + return false; + } + + const plain = this.serializeSelectionForClipboard(); + return this.writeNativeClipboard(view, event, plain, true); + } + + private writeNativeClipboard( + view: EditorView, + event: ClipboardEvent, + plain: string, + cut: boolean + ): boolean { + const payload = this.createRichClipboardPayload(view, plain); + const result = writeClipboardEvent(event, payload); + if (cut && !canDeleteAfterClipboardWrite(payload, result)) { + event.preventDefault(); + return true; + } + if (result.status === "failed") return false; + + if (cut) { + view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut")); + } return true; } + private isSelectionInsideNodeType(view: EditorView, typeName: string): boolean { + const { from, to } = view.state.selection; + let selectionInsideNode = false; + view.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === typeName && from >= pos && to <= pos + node.nodeSize) { + selectionInsideNode = true; + return false; + } + return !selectionInsideNode; + }); + return selectionInsideNode; + } + + private isClipboardEventFromCodeBlock(view: EditorView, event: ClipboardEvent): boolean { + const target = event.target as Element | null; + return ( + !!target?.closest?.(".milkup-code-block") && + this.isSelectionInsideNodeType(view, "code_block") + ); + } + + private isClipboardEventFromHtmlBlockEditor(view: EditorView, event: ClipboardEvent): boolean { + const target = event.target as Element | null; + return ( + !!target?.closest?.(".milkup-html-block-editor") && + this.isSelectionInsideNodeType(view, "html_block") + ); + } + + private getClipboardImageMode(): ImagePasteMethod { + return this.config.pasteConfig?.getImagePasteMethod?.() ?? getImagePasteMethod(); + } + + private buildClipboardPayloadForDom( + dom: HTMLElement, + plain: string, + sourceView: boolean, + imageMode?: ImagePasteMethod + ): ClipboardPayload { + return buildClipboardPayload(plain, dom, { + sourceView, + imageMode: imageMode ?? (sourceView ? undefined : this.getClipboardImageMode()), + ...getClipboardFontFamilies( + this.view.dom, + this.view.dom.querySelector(".cm-content") ?? undefined + ), + }); + } + + private createRichClipboardPayload(view: EditorView, plain: string): ClipboardPayload { + const sourceView = this.isSourceViewEnabled(); + const imageMode = sourceView ? undefined : this.getClipboardImageMode(); + try { + const renderedRoot = + sourceView || !shouldUseRenderedClipboardDom(view) + ? null + : getRenderedSelectionRoot(view, imageMode); + const dom = renderedRoot ?? view.serializeForClipboard(view.state.selection.content()).dom; + return this.buildClipboardPayloadForDom(dom, plain, sourceView, imageMode); + } catch (error) { + console.error("生成富文本剪贴板失败", error); + return { plain, html: "", richRequired: !sourceView }; + } + } + + private createCurrentTableClipboardPayload(): ClipboardPayload | null { + const tableInfo = this.getTableAtPosition(this.view.state.selection.from); + if (!tableInfo) return null; + + const doc = this.schema.topNodeType.create(null, tableInfo.node); + const plain = serializeMarkdown(doc).trimEnd(); + try { + const sourceView = this.isSourceViewEnabled(); + const imageMode = sourceView ? undefined : this.getClipboardImageMode(); + const renderedRoot = sourceView + ? null + : getRenderedNodeRoot(this.view, tableInfo.pos, imageMode); + const dom = renderedRoot ?? this.view.serializeForClipboard(new Slice(doc.content, 0, 0)).dom; + return this.buildClipboardPayloadForDom(dom, plain, sourceView, imageMode); + } catch (error) { + console.error("生成表格剪贴板失败", error); + return { plain, html: "", richRequired: true }; + } + } + /** * 创建右键菜单分隔线 */ @@ -975,22 +1121,35 @@ export class MilkupEditor implements IMilkupEditor { if (this._destroyed) return; // 复制 - const copyItem = this.createContextMenuItem("复制", !hasSelection, () => { - const text = this.serializeSelectionForClipboard(); - navigator.clipboard.writeText(text); + const copyItem = this.createContextMenuItem("复制", !hasSelection, async () => { + const plain = this.serializeSelectionForClipboard(); + const payload = this.createRichClipboardPayload(this.view, plain); + await writeClipboardPayload(payload); this.hideContextMenu(); }); menu.appendChild(copyItem); // 剪切 - const cutItem = this.createContextMenuItem("剪切", !hasSelection, () => { - const slice = this.view.state.selection.content(); - const text = this.serializeSliceToMarkdown(slice); - navigator.clipboard.writeText(text); - const tr = this.view.state.tr.deleteSelection(); - this.view.dispatch(tr); - this.hideContextMenu(); - }); + const cutItem = this.createContextMenuItem( + "剪切", + !hasSelection || !this.view.editable, + async () => { + if (!this.view.editable) return; + + const state = this.view.state; + const plain = this.serializeSelectionForClipboard(); + const payload = this.createRichClipboardPayload(this.view, plain); + const result = await writeClipboardPayload(payload); + const canDelete = this.view.editable && canDeleteAfterClipboardWrite(payload, result); + const selection = this.view.state.selection; + if (canDelete && this.view.state.doc === state.doc && selection.eq(state.selection)) { + this.view.dispatch( + this.view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut") + ); + } + this.hideContextMenu(); + } + ); menu.appendChild(cutItem); // 粘贴 - 使用 Clipboard API 读取内容并手动处理 @@ -1011,11 +1170,9 @@ export class MilkupEditor implements IMilkupEditor { menu.appendChild(this.createContextMenuSeparator()); menu.appendChild( - this.createContextMenuItem("复制表格", false, () => { - const tableMarkdown = this.getCurrentTableMarkdown(); - if (tableMarkdown) { - navigator.clipboard.writeText(tableMarkdown); - } + this.createContextMenuItem("复制表格", false, async () => { + const payload = this.createCurrentTableClipboardPayload(); + if (payload) await writeClipboardPayload(payload); this.hideContextMenu(); }) ); @@ -1136,7 +1293,7 @@ export class MilkupEditor implements IMilkupEditor { private createContextMenuItem( label: string, disabled: boolean, - onClick: () => void + onClick: () => void | Promise ): HTMLElement { const item = document.createElement("div"); item.className = "milkup-context-menu-item"; @@ -1148,7 +1305,7 @@ export class MilkupEditor implements IMilkupEditor { if (!disabled) { item.addEventListener("click", (e) => { e.stopPropagation(); - onClick(); + void onClick(); }); } @@ -1198,7 +1355,7 @@ export class MilkupEditor implements IMilkupEditor { if (!disabled) { item.addEventListener("click", (e) => { e.stopPropagation(); - onClick(); + void onClick(); }); } @@ -1822,14 +1979,6 @@ export class MilkupEditor implements IMilkupEditor { return lines.join("\n"); } - private getCurrentTableMarkdown(): string | null { - const tableInfo = this.getTableAtPosition(this.view.state.selection.from); - if (!tableInfo) return null; - - const doc = this.schema.topNodeType.create(null, tableInfo.node); - return serializeMarkdown(doc).trimEnd(); - } - private getCommonSelectedTable(from: number, to: number): { node: Node; pos: number } | null { const doc = this.view.state.doc; const startTable = this.getTableAtPosition(from); diff --git a/src/core/nodeviews/code-block.ts b/src/core/nodeviews/code-block.ts index 96b5b1ab..da9cd1bc 100644 --- a/src/core/nodeviews/code-block.ts +++ b/src/core/nodeviews/code-block.ts @@ -48,6 +48,14 @@ import { perl } from "@codemirror/legacy-modes/mode/perl"; import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; import { sourceViewManager } from "../decorations"; import { searchPluginKey } from "../plugins/search"; +import { + buildCodeClipboardPayload, + canDeleteAfterClipboardWrite, + createCodeClipboardExtension, + getCodeClipboardOptions, + requestClipboardPngFallback, + writeClipboardPayload, +} from "../clipboard"; /** Mermaid 显示模式 */ type MermaidDisplayMode = "code" | "mixed" | "diagram"; @@ -540,11 +548,18 @@ export class CodeBlockView implements NodeView { // 源码模式相关 private sourceViewMode: boolean = false; private sourceViewUnsubscribe: (() => void) | null = null; - - constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + private readonly isReadOnly: () => boolean; + + constructor( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined, + isReadOnly: () => boolean = () => false + ) { this.node = node; this.view = view; this.getPos = getPos; + this.isReadOnly = isReadOnly; this.languageCompartment = new Compartment(); this.themeCompartment = new Compartment(); this.lineNumbersCompartment = new Compartment(); @@ -703,6 +718,7 @@ export class CodeBlockView implements NodeView { this.languageCompartment.of(getLanguageExtension(normalizedLang)), this.lineNumbersCompartment.of(lineNumbers()), this.searchHighlightCompartment.of([]), + createCodeClipboardExtension(() => this.isReadOnly()), EditorView.updateListener.of((update) => this.onCMUpdate(update)), EditorView.domEventHandlers({ focus: () => this.forwardSelection(), @@ -808,6 +824,12 @@ export class CodeBlockView implements NodeView { const text = e.clipboardData?.getData("text/plain") || ""; document.execCommand("insertText", false, text); }); + this.sourceTextElement.addEventListener("copy", (e) => { + this.handleSourceClipboard(e as ClipboardEvent, false); + }); + this.sourceTextElement.addEventListener("cut", (e) => { + this.handleSourceClipboard(e as ClipboardEvent, true); + }); this.dom.insertBefore(this.sourceTextElement, this.editorContainer); } @@ -1029,6 +1051,34 @@ export class CodeBlockView implements NodeView { } } + private handleSourceClipboard(event: ClipboardEvent, cut: boolean): void { + const element = this.sourceTextElement; + const selection = window.getSelection(); + if (!element || !selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + if (!element.contains(range.startContainer) || !element.contains(range.endContainer)) return; + + const data = event.clipboardData; + if (!data) { + if (cut) event.preventDefault(); + return; + } + + try { + data.clearData(); + data.setData("text/plain", selection.toString()); + event.preventDefault(); + } catch { + if (cut) event.preventDefault(); + return; + } + + if (cut && !this.isReadOnly()) { + document.execCommand("delete"); + } + } + /** * 创建头部(语言选择器和 Mermaid 模式选择器) */ @@ -1062,14 +1112,16 @@ export class CodeBlockView implements NodeView { copyBtn.innerHTML = ``; copyBtn.addEventListener("click", (e) => { e.stopPropagation(); - this.copyCodeBlock(); - // 短暂显示已复制反馈 - copyBtn.classList.add("copied"); - copyBtn.innerHTML = ``; - setTimeout(() => { - copyBtn.classList.remove("copied"); - copyBtn.innerHTML = ``; - }, 1500); + void this.copyCodeBlock().then((copied) => { + if (!copied) return; + // 短暂显示已复制反馈 + copyBtn.classList.add("copied"); + copyBtn.innerHTML = ``; + setTimeout(() => { + copyBtn.classList.remove("copied"); + copyBtn.innerHTML = ``; + }, 1500); + }); }); header.appendChild(copyBtn); @@ -1155,8 +1207,15 @@ export class CodeBlockView implements NodeView { /** * 复制代码块到剪贴板 */ - private copyCodeBlock(): void { - navigator.clipboard.writeText(this.cm.state.doc.toString()); + private createCodeClipboardPayload(text: string) { + return buildCodeClipboardPayload(text, getCodeClipboardOptions(this.cm)); + } + + private async copyCodeBlock(): Promise { + const result = await writeClipboardPayload( + this.createCodeClipboardPayload(this.cm.state.doc.toString()) + ); + return result.status !== "failed"; } /** @@ -1193,22 +1252,39 @@ export class CodeBlockView implements NodeView { } // 复制 - const copyItem = this.createContextMenuItem("复制", !hasSelection, () => { + const copyItem = this.createContextMenuItem("复制", !hasSelection, async () => { const selectedText = this.cm.state.sliceDoc(main.from, main.to); - navigator.clipboard.writeText(selectedText); + await writeClipboardPayload(this.createCodeClipboardPayload(selectedText)); this.hideContextMenu(); }); menu.appendChild(copyItem); // 剪切 - const cutItem = this.createContextMenuItem("剪切", !hasSelection, () => { - const selectedText = this.cm.state.sliceDoc(main.from, main.to); - navigator.clipboard.writeText(selectedText); - this.cm.dispatch({ - changes: { from: main.from, to: main.to, insert: "" }, - }); - this.hideContextMenu(); - }); + const cutItem = this.createContextMenuItem( + "剪切", + !hasSelection || this.isReadOnly(), + async () => { + if (this.isReadOnly()) return; + const state = this.cm.state; + const { from, to } = state.selection.main; + const selectedText = state.sliceDoc(from, to); + const payload = this.createCodeClipboardPayload(selectedText); + const result = await writeClipboardPayload(payload); + const current = this.cm.state.selection.main; + if ( + !this.isReadOnly() && + canDeleteAfterClipboardWrite(payload, result) && + this.cm.state.doc === state.doc && + current.eq(state.selection.main) + ) { + this.cm.dispatch({ + changes: { from, to, insert: "" }, + userEvent: "delete.cut", + }); + } + this.hideContextMenu(); + } + ); menu.appendChild(cutItem); // 粘贴 - 使用 Clipboard API 读取文本 @@ -1236,7 +1312,7 @@ export class CodeBlockView implements NodeView { // 复制代码块 const copyBlockItem = this.createContextMenuItem("复制代码块", false, () => { - this.copyCodeBlock(); + void this.copyCodeBlock(); this.hideContextMenu(); }); menu.appendChild(copyBlockItem); @@ -1290,7 +1366,7 @@ export class CodeBlockView implements NodeView { private createContextMenuItem( label: string, disabled: boolean, - onClick: () => void + onClick: () => void | Promise ): HTMLElement { const item = document.createElement("div"); item.className = "milkup-context-menu-item"; @@ -1302,7 +1378,7 @@ export class CodeBlockView implements NodeView { if (!disabled) { item.addEventListener("click", (e) => { e.stopPropagation(); - onClick(); + void onClick(); }); } @@ -1469,6 +1545,13 @@ export class CodeBlockView implements NodeView { this.mermaidPreview.style.display = "block"; break; } + + if (this.mermaidDisplayMode !== "code") { + const renderedSvg = this.mermaidPreview.querySelector("svg"); + if (renderedSvg && !renderedSvg.hasAttribute("data-clipboard-png")) { + requestClipboardPngFallback(renderedSvg); + } + } } /** @@ -1503,6 +1586,8 @@ export class CodeBlockView implements NodeView { padMermaidSvgBounds(preview); // 根据实际背景色修正文本颜色 fixMermaidTextContrast(preview); + const renderedSvg = preview.querySelector("svg"); + if (renderedSvg) requestClipboardPngFallback(renderedSvg); } catch (error) { // mermaid.render() 语法错误时会在 removeTempElements() 之前抛出异常, // 导致临时 DOM 元素(
/ / 输入数学公式...'; + if (html && !this.isEditing) requestClipboardPngFallback(this.preview); } private setEditing(editing: boolean): void { if (this.isEditing === editing) return; this.isEditing = editing; this.dom.classList.toggle("editing", editing); + if (!editing && this.preview.innerHTML && !this.preview.hasAttribute("data-clipboard-png")) { + requestClipboardPngFallback(this.preview); + } } private enterEditMode(): void { diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 8d86d1d2..009ad83b 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -59,6 +59,8 @@ export interface MilkupEditor { destroy: () => void; /** 聚焦编辑器 */ focus: () => void; + /** 在编辑器重新可见后重试异步 PNG 剪贴板回退 */ + refreshClipboardFallbacks: () => void; /** 获取光标位置(在源码中的偏移量) */ getCursorOffset: () => number; /** 设置光标位置 */ diff --git a/src/core/utils/html-entities.ts b/src/core/utils/html-entities.ts index 90ce1609..51620a7c 100644 --- a/src/core/utils/html-entities.ts +++ b/src/core/utils/html-entities.ts @@ -1,4 +1,6 @@ -import { decode as decodeHtml } from "he"; +import he from "he"; + +const { decode: decodeHtml } = he; export const HTML_ENTITY_SYNTAX_TYPE = "html_entity"; diff --git a/src/main/ipcBridge.ts b/src/main/ipcBridge.ts index 9fa0cca0..e7fd8c73 100644 --- a/src/main/ipcBridge.ts +++ b/src/main/ipcBridge.ts @@ -1223,9 +1223,32 @@ export function registerGlobalIpcHandlers() { wslDirWatcher.unwatchAll(); }); + ipcMain.handle( + "clipboard:write", + async (_event, payload: { text?: unknown; html?: unknown }): Promise => { + const text = typeof payload?.text === "string" ? payload.text : ""; + const html = typeof payload?.html === "string" ? payload.html : ""; + try { + if (html) { + clipboard.write({ text, html }); + } else { + clipboard.writeText(text); + } + return true; + } catch (error) { + console.error("写入剪贴板失败", error); + return false; + } + } + ); ipcMain.handle("clipboard:writeText", async (_event, text: string): Promise => { - clipboard.writeText(text ?? ""); - return true; + try { + clipboard.writeText(text ?? ""); + return true; + } catch (error) { + console.error("写入剪贴板失败", error); + return false; + } }); ipcMain.handle( "image:openPreview", diff --git a/src/preload.ts b/src/preload.ts index 2bce8d7a..f7fddf55 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -76,6 +76,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ) => ipcRenderer.invoke("image:openPreview", { src, alt, ...options }), getFilePathInClipboard: () => ipcRenderer.invoke("clipboard:getFilePath"), writeTextToClipboard: (text: string) => ipcRenderer.invoke("clipboard:writeText", text), + writeToClipboard: (payload: { text: string; html: string }) => + ipcRenderer.invoke("clipboard:write", payload), writeTempImage: ( file: Uint8Array, targetPath: string, diff --git a/src/renderer/components/editor/MilkupEditor.vue b/src/renderer/components/editor/MilkupEditor.vue index 7ed50e29..fcfefa3d 100644 --- a/src/renderer/components/editor/MilkupEditor.vue +++ b/src/renderer/components/editor/MilkupEditor.vue @@ -273,6 +273,9 @@ function createEditorInstance() { }; editor = createMilkupEditor(containerRef.value, config); + if (props.isActive) { + void nextTick().then(() => editor?.refreshClipboardFallbacks()); + } // 监听变更事件 editor.on("change", ({ markdown }: { markdown: string }) => { @@ -509,6 +512,7 @@ watch( emitOutlineUpdate(); // 通知源码模式状态 emitter.emit("sourceView:changed", editor?.isSourceViewEnabled() ?? false); + void nextTick().then(() => editor?.refreshClipboardFallbacks()); } } ); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 59f8f7a8..f2567ec8 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -46,6 +46,7 @@ interface Window { ) => Promise; getFilePathInClipboard: () => Promise; writeTextToClipboard: (text: string) => Promise; + writeToClipboard: (payload: { text: string; html: string }) => Promise; writeTempImage: ( file: Uint8Array, targetPath: string, diff --git a/test/clipboard.test.mjs b/test/clipboard.test.mjs new file mode 100644 index 00000000..f6e66fcf --- /dev/null +++ b/test/clipboard.test.mjs @@ -0,0 +1,862 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import { Window } from "happy-dom"; +import { + buildClipboardPayload, + buildCodeClipboardPayload, + buildSelfContainedMarkup, + cacheClipboardPng, + canDeleteAfterClipboardWrite, + getClipboardFontFamilies, + getCodeClipboardSelection, + getRenderedNodeRoot, + hasRenderedClipboardNode, + getRenderedSelectionRoot, + isClipboardElementVisible, + refreshClipboardFallbacks, + semanticizeClipboardDom, + resolveClipboardImageSource, + writeClipboardEvent, + writeClipboardPayload, +} from "../src/core/clipboard.ts"; + +const window = new Window(); +const previousGlobals = { + window: globalThis.window, + document: globalThis.document, + Node: globalThis.Node, + NodeFilter: globalThis.NodeFilter, + HTMLElement: globalThis.HTMLElement, +}; + +Object.assign(globalThis, { + window, + document: window.document, + Node: window.Node, + NodeFilter: window.NodeFilter, + HTMLElement: window.HTMLElement, +}); + +after(() => { + window.close(); + Object.assign(globalThis, previousGlobals); +}); + +function makeRoot(html) { + const root = document.createElement("div"); + root.innerHTML = html; + return root; +} + +test("semanticizeClipboardDom removes syntax markers but keeps semantic elements", () => { + const root = makeRoot( + '

# Title

' + + '

**bold' + + '**' + + '[site' + + '](/target)' + + '<u>under' + + '&

' + ); + + const result = semanticizeClipboardDom(root); + + assert.ok(result); + assert.equal(result.querySelector("h1")?.textContent, "Title"); + assert.equal(result.querySelector("strong")?.textContent, "bold"); + assert.equal(result.querySelector("a")?.getAttribute("href"), "/target"); + assert.equal(result.querySelector("a")?.textContent, "site"); + assert.equal(result.querySelector("u")?.textContent, "under"); + assert.equal(result.querySelectorAll("strong")[1]?.textContent, "&"); + assert.equal(result.querySelector("p")?.textContent, "boldsiteunder&"); + assert.equal(result.innerHTML.includes("**"), false); + assert.equal(result.querySelectorAll(".milkup-syntax").length, 0); + assert.equal(result.querySelector("[data-pm-slice]")?.getAttribute("data-pm-slice"), "0 0 []"); + assert.equal(root.querySelectorAll(".milkup-syntax").length, 7); +}); + +test("buildClipboardPayload adds table borders", () => { + const root = makeRoot("
名称合同
"); + + const payload = buildClipboardPayload("| 名称 | 合同 |", root); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + + assert.equal(payload.plain, "| 名称 | 合同 |"); + assert.equal(parsed.querySelector("table")?.getAttribute("border"), "1"); + assert.equal(parsed.querySelector("th")?.style.border, "1px solid #cccccc"); + assert.equal(parsed.querySelector("td")?.style.border, "1px solid #cccccc"); +}); + +test("buildClipboardPayload carries the configured fonts into HTML", () => { + const root = makeRoot("

中文

const value = 1
"); + const payload = buildClipboardPayload("中文", root, { + fontFamily: '"UserConfiguredSans", sans-serif', + codeFontFamily: '"UserConfiguredCode", monospace', + }); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + const host = parsed.body.firstElementChild; + const paragraph = parsed.querySelector("p"); + const pre = parsed.querySelector("pre"); + + assert.match(host?.style.fontFamily ?? "", /UserConfiguredSans/); + assert.match(host?.style.fontFamily ?? "", /sans-serif/); + assert.match(paragraph?.style.fontFamily ?? "", /UserConfiguredSans/); + assert.match(pre?.style.fontFamily ?? "", /UserConfiguredCode/); +}); + +test("reads font families from the current runtime CSS", () => { + const previous = globalThis.getComputedStyle; + globalThis.getComputedStyle = () => ({ + fontFamily: "runtime-fallback", + getPropertyValue(name) { + if (name === "--milkup-font-default") return '"ConfiguredSans", sans-serif;'; + if (name === "--milkup-font-code") return '"ConfiguredCode", monospace;'; + return ""; + }, + }); + + try { + assert.deepEqual(getClipboardFontFamilies(document.createElement("div")), { + fontFamily: '"ConfiguredSans", sans-serif', + codeFontFamily: '"ConfiguredCode", monospace', + }); + } finally { + if (previous === undefined) delete globalThis.getComputedStyle; + else globalThis.getComputedStyle = previous; + } +}); + +test("buildClipboardPayload prefers a cached local image data URL", () => { + const root = makeRoot( + '' + ); + const payload = buildClipboardPayload("![图](local.png)", root, { imageMode: "local" }); + assert.match(payload.html, /src="data:image\/png;base64,cached"/); +}); + +test("resolveClipboardImageSource makes the three image modes explicit", () => { + const local = makeRoot( + '' + ).querySelector("img"); + const remote = makeRoot( + '' + ).querySelector("img"); + const base64 = makeRoot('').querySelector("img"); + const base64Cached = makeRoot( + '' + ).querySelector("img"); + + assert.equal(resolveClipboardImageSource(local, "local"), "data:image/png;base64,local"); + assert.equal(resolveClipboardImageSource(remote, "remote"), "https://example.com/image.png"); + assert.equal(resolveClipboardImageSource(base64, "base64"), "local.png"); + assert.equal(resolveClipboardImageSource(base64Cached, "base64"), "data:image/png;base64,base64"); + assert.equal(local.getAttribute("data-clipboard-src"), "data:image/png;base64,local"); + assert.equal(remote.getAttribute("data-clipboard-src"), "data:image/png;base64,remote"); + + const payload = buildClipboardPayload( + "![图](remote.png)", + makeRoot( + '' + ), + { imageMode: "remote" } + ); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + assert.equal(parsed.querySelector("img")?.getAttribute("src"), "https://example.com/image.png"); +}); + +test("rendered flow content does not inline the editor's full computed style", () => { + const previousGetComputedStyle = globalThis.getComputedStyle; + const computedValues = { + color: "rgb(1, 2, 3)", + backgroundColor: "rgb(4, 5, 6)", + fontSize: "18px", + textAlign: "left", + cursor: "default", + userSelect: "none", + caretColor: "transparent", + display: "block", + visibility: "visible", + width: "640px", + whiteSpace: "pre", + }; + for (let index = 0; index < 300; index += 1) { + computedValues[`--editor-internal-${index}`] = `${index}px`; + } + computedValues[Symbol.iterator] = function* () { + yield* Object.keys(computedValues); + }; + const computedStyle = { + ...computedValues, + length: Object.keys(computedValues).length, + item: (index) => Object.keys(computedValues)[index] ?? "", + getPropertyValue: (property) => computedValues[property] ?? "", + }; + globalThis.getComputedStyle = () => computedStyle; + + const editorDom = makeRoot( + "

flow text

cell
" + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + try { + const rendered = getRenderedSelectionRoot({ dom: editorDom }); + const payload = buildClipboardPayload("flow text\ncell", rendered); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + assert.ok(payload.html.length < 10_000, `clipboard HTML expanded to ${payload.html.length} characters`); + assert.doesNotMatch(payload.html, /--editor-internal-/); + assert.equal(parsed.querySelector("p")?.style.color, ""); + assert.equal(parsed.querySelector("p")?.style.cursor, ""); + assert.equal(parsed.querySelector("p")?.style.whiteSpace, "normal"); + assert.equal(parsed.querySelector("table")?.style.width, ""); + } finally { + editorDom.remove(); + if (previousGetComputedStyle === undefined) delete globalThis.getComputedStyle; + else globalThis.getComputedStyle = previousGetComputedStyle; + } +}); + +test("getRenderedSelectionRoot clones visible DOM and drops editor controls", () => { + const editorDom = makeRoot( + '
JavaScript
' + + '
1
' + + '
one
two
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.ok(result); + assert.equal(result.querySelector("pre code")?.textContent, "one\ntwo"); + assert.equal(result.querySelector(".milkup-code-block-header"), null); + assert.equal(result.querySelector(".cm-gutters"), null); + editorDom.remove(); +}); + +test("hasRenderedClipboardNode detects bounded nodes in cross-block slices", () => { + const fragment = (names) => ({ + descendants(callback) { + for (const name of names) callback({ type: { name } }); + }, + }); + + assert.equal(hasRenderedClipboardNode(fragment(["paragraph", "code_block"])), true); + assert.equal(hasRenderedClipboardNode(fragment(["paragraph", "html_block"])), true); + assert.equal(hasRenderedClipboardNode(fragment(["paragraph"])), false); +}); + +test("semantic clipboard flow removes editor hard-wrap and width styles", () => { + const root = makeRoot( + '

这是一段需要自适应换行的长文本。

' + ); + root.style.whiteSpace = "pre-wrap"; + root.style.width = "800px"; + const payload = buildClipboardPayload("这是一段需要自适应换行的长文本。", root); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + const host = parsed.body.firstElementChild; + const paragraph = parsed.querySelector("p"); + + assert.equal(host?.style.whiteSpace, "normal"); + assert.equal(host?.style.width, ""); + assert.equal(paragraph?.style.whiteSpace, "normal"); + assert.equal(paragraph?.style.wordBreak, "normal"); + assert.equal(paragraph?.style.overflowWrap, "break-word"); + assert.equal(paragraph?.style.width, ""); + assert.equal(paragraph?.style.maxWidth, ""); + assert.equal(paragraph?.style.height, ""); +}); + +test("rendered mixed selections keep code layout without leaking it into prose", () => { + const editorDom = makeRoot( + '

before

' + + '
const value = 1
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.querySelector("p")?.style.whiteSpace, ""); + assert.equal(result?.querySelector("p")?.style.wordBreak, ""); + assert.equal(result?.querySelector("pre")?.style.whiteSpace, "pre"); + + const payload = buildClipboardPayload("before\\nconst value = 1", result); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + assert.equal(parsed.querySelector("p")?.style.whiteSpace, "normal"); + assert.equal(parsed.querySelector("pre")?.style.whiteSpace, "pre-wrap"); + editorDom.remove(); +}); + +test("mixed rendered selections do not carry source table geometry", () => { + const editorDom = makeRoot( + '

before

cell
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const rendered = getRenderedSelectionRoot({ dom: editorDom }); + const payload = buildClipboardPayload("before\\ncell", rendered); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + + assert.equal(parsed.querySelector("table")?.style.width, ""); + assert.equal(parsed.querySelector("table")?.getAttribute("width"), null); + assert.equal(parsed.querySelector("td")?.style.minWidth, ""); + assert.equal(parsed.querySelector("td")?.getAttribute("width"), null); + editorDom.remove(); +}); + +test("code blocks drop source geometry and reflow long lines in final HTML", () => { + const editorDom = makeRoot( + '
const longLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const rendered = getRenderedSelectionRoot({ dom: editorDom }); + const payload = buildClipboardPayload("code", rendered); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + const pre = parsed.querySelector("pre"); + const code = parsed.querySelector("pre code"); + + assert.equal(pre?.style.width, ""); + assert.equal(pre?.style.whiteSpace, "pre-wrap"); + assert.equal(code?.style.minWidth, ""); + assert.equal(code?.style.whiteSpace, "pre-wrap"); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot drops prose and table viewport geometry", () => { + const editorDom = makeRoot( + '

text

' + + '
cell
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + const paragraph = result?.querySelector("p"); + const table = result?.querySelector("table"); + assert.equal(paragraph?.style.width, ""); + assert.equal(paragraph?.style.maxWidth, ""); + assert.equal(paragraph?.style.height, ""); + assert.equal(paragraph?.style.whiteSpace, ""); + assert.equal(paragraph?.style.wordBreak, ""); + assert.equal(paragraph?.style.fontSize, "18px"); + assert.equal(table?.style.width, ""); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot removes hidden source widgets", () => { + const editorDom = makeRoot( + '
visible
' + + '
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.textContent?.includes("visible"), true); + assert.equal(result?.textContent?.includes("secret source"), false); + assert.equal(result?.querySelector(".math-source-container"), null); + editorDom.remove(); +}); + +test("refreshClipboardFallbacks skips hidden targets and retries after visibility returns", async () => { + const root = makeRoot('
formula
'); + document.body.appendChild(root); + const calls = []; + const rasterize = async (element) => { + calls.push(element); + return null; + }; + + try { + assert.equal(isClipboardElementVisible(root.firstElementChild), true); + refreshClipboardFallbacks(root, rasterize); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1); + + root.style.display = "none"; + refreshClipboardFallbacks(root, rasterize); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1); + + root.style.display = ""; + refreshClipboardFallbacks(root, rasterize); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 2); + } finally { + root.remove(); + } +}); + +test("getRenderedSelectionRoot keeps prose-to-code selections in one rich fragment", () => { + const editorDom = makeRoot( + '

before

JavaScript
' + + '
1
' + + '
const value = 1
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.setStart(editorDom.querySelector("p").firstChild, 0); + range.setEnd( + editorDom.querySelector(".cm-line").firstChild, + editorDom.querySelector(".cm-line").textContent.length + ); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.querySelector("p")?.textContent, "before"); + assert.equal(result?.querySelector("pre code")?.textContent, "const value = 1"); + assert.equal(result?.querySelector(".milkup-code-block-header"), null); + assert.equal(result?.querySelector(".cm-gutters"), null); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot keeps prose-to-HTML selections rendered", () => { + const editorDom = makeRoot( + '

before

after
' + + '
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.setStart(editorDom.querySelector("p").firstChild, 0); + range.setEnd(editorDom.querySelector("b").firstChild, 5); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.querySelector("p")?.textContent, "before"); + assert.equal(result?.querySelector("b")?.textContent, "after"); + assert.equal(result?.querySelector(".milkup-html-block-editor"), null); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot renders an HTML block editor semantically", () => { + const editorDom = makeRoot( + '
1
' + + '
<b>x</b>
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.querySelector("b")?.textContent, "x"); + assert.equal(result?.querySelector("pre code"), null); + assert.equal(result?.querySelector(".cm-gutters"), null); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot turns task markers into semantic checkboxes", () => { + const editorDom = makeRoot( + '
  • ✓' + + '- [x] done
' + ); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ dom: editorDom }); + assert.equal(result?.querySelector("input[type=checkbox]")?.checked, true); + assert.equal(result?.querySelector(".milkup-list-marker"), null); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot falls back to ProseMirror DOM positions", () => { + const editorDom = makeRoot("

one

two

"); + document.body.appendChild(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + const collapsed = document.createRange(); + collapsed.setStart(editorDom, 0); + collapsed.collapse(true); + selection.addRange(collapsed); + + const result = getRenderedSelectionRoot({ + dom: editorDom, + state: { selection: { empty: false, from: 1, to: 7 } }, + domAtPos(pos) { + return { node: editorDom, offset: pos === 1 ? 0 : 2 }; + }, + }); + assert.equal(result?.textContent, "onetwo"); + editorDom.remove(); +}); + +test("getRenderedSelectionRoot preserves the ProseMirror slice marker", () => { + const editorDom = makeRoot("

text

"); + document.body.appendChild(editorDom); + const range = document.createRange(); + range.selectNodeContents(editorDom); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const result = getRenderedSelectionRoot({ + dom: editorDom, + state: { selection: { empty: false, content: () => ({}) } }, + serializeForClipboard: () => ({ + dom: makeRoot('

text

'), + }), + }); + assert.equal(result?.getAttribute("data-pm-slice"), "0 1 []"); + assert.equal(result?.firstElementChild?.getAttribute("data-pm-slice"), "0 1 []"); + editorDom.remove(); +}); + +test("getRenderedNodeRoot keeps the visible image element", () => { + const imageNode = makeRoot( + '
图
' + ); + const result = getRenderedNodeRoot({ nodeDOM: () => imageNode }, 0); + assert.equal(result?.querySelector("img")?.getAttribute("src"), "image.png"); +}); + +test("buildClipboardPayload keeps an SVG primary and one PNG fallback", () => { + const root = makeRoot( + '' + ); + const payload = buildClipboardPayload("![图表](chart.svg)", root); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + const source = parsed.querySelector("picture source"); + const image = parsed.querySelector("picture img"); + + assert.equal(source?.getAttribute("type"), "image/svg+xml"); + assert.equal(image?.getAttribute("src"), "data:image/png;base64,fallback"); + assert.equal(source?.getAttribute("srcset")?.includes("data-clipboard-png"), false); + assert.equal((payload.html.match(/data:image\/png;base64,fallback/g) || []).length, 1); +}); + +test("buildClipboardPayload keeps KaTeX HTML and a single PNG fallback", () => { + const root = makeRoot( + '
x
' + ); + const payload = buildClipboardPayload("$$x$$", root); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + + assert.equal(parsed.querySelector(".math-preview .katex")?.textContent, "x"); + assert.equal( + parsed.querySelector("img[data-clipboard-fallback-image]")?.getAttribute("src"), + "data:image/png;base64,math" + ); + assert.equal((payload.html.match(/data:image\/png;base64,math/g) || []).length, 1); +}); + +test("self-contained raster markup includes runtime styles without mutating the source", () => { + const fontStyle = document.createElement("style"); + fontStyle.textContent = + "@font-face { font-family: RuntimeClipboardFont; src: url(runtime.woff2); }"; + document.head.appendChild(fontStyle); + const element = makeRoot('').firstElementChild; + + try { + const result = buildSelfContainedMarkup(element); + assert.equal(result.isSvg, true); + assert.match(result.markup, /color: red/); + assert.match(result.markup, /@font-face/); + assert.equal(element.getAttribute("data-clipboard-png"), null); + } finally { + fontStyle.remove(); + } +}); + +test("cacheClipboardPng leaves the primary usable when rasterization fails", async () => { + const element = makeRoot( + '
x
' + ).firstElementChild; + const result = await cacheClipboardPng(element); + + assert.equal(result, null); + assert.equal(element.querySelector(".katex")?.textContent, "x"); + assert.equal(element.getAttribute("data-clipboard-png"), null); +}); + +test("buildClipboardPayload keeps plain text only in source view", () => { + const root = makeRoot('

- item

'); + const payload = buildClipboardPayload("- item", root, { sourceView: true }); + + assert.equal(payload.plain, "- item"); + assert.equal(payload.html, ""); +}); + +test("buildCodeClipboardPayload creates a semantic pre/code fragment", () => { + const payload = buildCodeClipboardPayload("const answer = 42"); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + + assert.equal(payload.plain, "const answer = 42"); + assert.equal(parsed.querySelector("pre code")?.textContent, "const answer = 42"); +}); + +test("buildCodeClipboardPayload carries the current code style", () => { + const payload = buildCodeClipboardPayload("const answer = 42", { + codeStyle: "font-size: 20px; line-height: 1.6; white-space: pre;", + }); + const parsed = new window.DOMParser().parseFromString(payload.html, "text/html"); + assert.equal(parsed.querySelector("pre")?.style.fontSize, "20px"); + assert.equal(parsed.querySelector("code")?.style.whiteSpace, "pre-wrap"); +}); + +test("getCodeClipboardSelection preserves CodeMirror linewise copy semantics", () => { + const view = { + state: { + selection: { ranges: [{ empty: true, from: 2 }] }, + doc: { + length: 8, + lineAt: () => ({ number: 1, from: 0, to: 3, text: "one" }), + }, + lineBreak: "\n", + }, + }; + + assert.deepEqual(getCodeClipboardSelection(view), { + text: "one", + ranges: [{ from: 0, to: 4 }], + }); +}); + +test("writeClipboardEvent writes both formats and reports a rich result", () => { + const values = new Map(); + const event = { + clipboardData: { + clearData() { + values.clear(); + }, + setData(type, value) { + values.set(type, value); + }, + }, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + + const result = writeClipboardEvent(event, { plain: "# Title", html: "

Title

" }); + + assert.deepEqual(result, { status: "rich" }); + assert.equal(event.defaultPrevented, true); + assert.equal(values.get("text/plain"), "# Title"); + assert.equal(values.get("text/html"), "

Title

"); +}); + +test("writeClipboardEvent falls back to plain text when HTML cannot be written", () => { + const values = new Map(); + const event = { + clipboardData: { + clearData() { + values.clear(); + }, + setData(type, value) { + if (type === "text/html") throw new Error("HTML is unavailable"); + values.set(type, value); + }, + }, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + + const result = writeClipboardEvent(event, { plain: "# Title", html: "

Title

" }); + + assert.deepEqual(result, { status: "plain" }); + assert.equal(event.defaultPrevented, true); + assert.equal(values.get("text/plain"), "# Title"); + assert.equal(values.has("text/html"), false); +}); + +test("writeClipboardPayload writes both formats through the browser Clipboard API", async () => { + let writtenItem; + const restore = installClipboardEnvironment({ + write: async (items) => { + writtenItem = items[0]; + }, + writeText: async () => { + throw new Error("plain fallback should not be used"); + }, + electronAPI: {}, + }); + + try { + const result = await writeClipboardPayload({ plain: "# Title", html: "

Title

" }); + + assert.deepEqual(result, { status: "rich" }); + assert.deepEqual(Object.keys(writtenItem.data).sort(), ["text/html", "text/plain"]); + } finally { + restore(); + } +}); + +test("writeClipboardPayload uses the Electron bridge for dual-format fallback", async () => { + let electronPayload; + let plainWriteCount = 0; + const restore = installClipboardEnvironment({ + write: async () => { + throw new Error("browser rich clipboard is unavailable"); + }, + writeText: async () => { + plainWriteCount += 1; + }, + electronAPI: { + writeToClipboard: async (payload) => { + electronPayload = payload; + return true; + }, + }, + }); + + try { + const result = await writeClipboardPayload({ plain: "# Title", html: "

Title

" }); + + assert.deepEqual(result, { status: "rich" }); + assert.deepEqual(electronPayload, { text: "# Title", html: "

Title

" }); + assert.equal(plainWriteCount, 0); + } finally { + restore(); + } +}); + +test("writeClipboardPayload distinguishes plain fallback from failure", async () => { + const restore = installClipboardEnvironment({ + write: async () => { + throw new Error("rich clipboard is unavailable"); + }, + writeText: async () => undefined, + electronAPI: { + writeToClipboard: async () => false, + }, + }); + + try { + assert.deepEqual(await writeClipboardPayload({ plain: "plain", html: "

rich

" }), { + status: "plain", + }); + } finally { + restore(); + } + + const restoreFailure = installClipboardEnvironment({ + write: async () => { + throw new Error("rich clipboard is unavailable"); + }, + writeText: async () => { + throw new Error("plain clipboard is unavailable"); + }, + electronAPI: { + writeToClipboard: async () => false, + writeTextToClipboard: async () => false, + }, + }); + + try { + assert.deepEqual(await writeClipboardPayload({ plain: "plain", html: "

rich

" }), { + status: "failed", + }); + } finally { + restoreFailure(); + } +}); + +test("canDeleteAfterClipboardWrite only accepts rich writes for rich payloads", () => { + assert.equal( + canDeleteAfterClipboardWrite({ plain: "text", html: "

text

" }, { status: "rich" }), + true + ); + assert.equal( + canDeleteAfterClipboardWrite({ plain: "text", html: "

text

" }, { status: "plain" }), + false + ); + assert.equal( + canDeleteAfterClipboardWrite( + { plain: "source", html: "", richRequired: false }, + { status: "plain" } + ), + true + ); + assert.equal( + canDeleteAfterClipboardWrite( + { plain: "text", html: "", richRequired: true }, + { status: "plain" } + ), + false + ); + assert.equal( + canDeleteAfterClipboardWrite({ plain: "text", html: "

text

" }, { status: "failed" }), + false + ); +}); + +function installClipboardEnvironment({ write, writeText, electronAPI }) { + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const previousClipboardItem = Object.getOwnPropertyDescriptor(globalThis, "ClipboardItem"); + const previousElectronAPI = window.electronAPI; + const clipboard = {}; + if (write) clipboard.write = write; + if (writeText) clipboard.writeText = writeText; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { clipboard }, + }); + Object.defineProperty(globalThis, "ClipboardItem", { + configurable: true, + value: class MockClipboardItem { + constructor(data) { + this.data = data; + } + }, + }); + window.electronAPI = electronAPI; + + return () => { + if (previousNavigator) { + Object.defineProperty(globalThis, "navigator", previousNavigator); + } else { + delete globalThis.navigator; + } + if (previousClipboardItem) { + Object.defineProperty(globalThis, "ClipboardItem", previousClipboardItem); + } else { + delete globalThis.ClipboardItem; + } + if (previousElectronAPI === undefined) delete window.electronAPI; + else window.electronAPI = previousElectronAPI; + }; +} diff --git a/test/editor-clipboard.test.mjs b/test/editor-clipboard.test.mjs new file mode 100644 index 00000000..10d9b0b5 --- /dev/null +++ b/test/editor-clipboard.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test, { after } from "node:test"; +import { fileURLToPath } from "node:url"; +import { Window } from "happy-dom"; +import { AllSelection } from "prosemirror-state"; +import { createServer } from "vite"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const window = new Window({ url: "http://localhost" }); +const previousGlobals = new Map( + ["window", "document", "Node", "NodeFilter", "HTMLElement", "localStorage", "getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"].map( + (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] + ) +); + +Object.assign(globalThis, { + window, + document: window.document, + Node: window.Node, + NodeFilter: window.NodeFilter, + HTMLElement: window.HTMLElement, + localStorage: window.localStorage, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), +}); +Object.defineProperty(globalThis, "navigator", { + value: window.navigator, + configurable: true, +}); + +const server = await createServer({ + configFile: false, + root: projectRoot, + server: { middlewareMode: true }, +}); +const { MilkupEditor } = await server.ssrLoadModule("/src/core/editor.ts"); + +after(async () => { + window.close(); + await server.close(); + for (const [key, descriptor] of previousGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } +}); + +test("mixed editor copy writes reflowable HTML without viewport geometry", () => { + const editorStyles = document.createElement("style"); + editorStyles.textContent = readFileSync( + new URL("../src/core/styles/milkup.css", import.meta.url), + "utf8" + ); + document.head.appendChild(editorStyles); + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = new MilkupEditor(container, { + content: [ + "## 标题", + "", + "这是一段用于验证复制行为的普通文字。", + "", + "| 模块 | 状态 | 说明 |", + "| --- | --- | --- |", + "| 测试 | 进行中 | 这是一段比较长的单元格内容,用于检查窄窗口中的换行。 |", + ].join("\n"), + }); + + try { + editor.view.dispatch(editor.view.state.tr.setSelection(new AllSelection(editor.view.state.doc))); + const writes = new Map(); + const event = new window.Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + clearData: () => writes.clear(), + setData: (type, value) => writes.set(type, value), + }, + }); + + const handled = editor.view.someProp("handleDOMEvents", (handlers) => + handlers.copy ? handlers.copy(editor.view, event) : false + ); + + assert.equal(handled, true); + assert.equal(event.defaultPrevented, true); + assert.deepEqual([...writes.keys()], ["text/plain", "text/html"]); + + const html = writes.get("text/html"); + assert.ok(html.length < 50_000, `clipboard HTML expanded to ${html.length} characters`); + const parsed = new window.DOMParser().parseFromString(html, "text/html"); + const table = parsed.querySelector("table"); + const cell = parsed.querySelector("td"); + assert.ok(table); + assert.ok(cell); + assert.equal(table.style.width, ""); + assert.equal(table.getAttribute("width"), null); + assert.equal(cell.style.minWidth, ""); + assert.equal(cell.style.whiteSpace, "normal"); + assert.equal(parsed.querySelectorAll("br").length, 0); + } finally { + editor.destroy(); + container.remove(); + editorStyles.remove(); + } +}); diff --git a/tsconfig.json b/tsconfig.json index d9d7d4a1..46dbb914 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "baseUrl": "./", "module": "ESNext", "moduleResolution": "Node", + "allowImportingTsExtensions": true, "paths": { "@/*": ["src/*"], "@renderer/*": ["src/renderer/*"],