From abfaeb3d85bb9f2bcd13a0583f28a32526ed9bdb Mon Sep 17 00:00:00 2001 From: yoma Date: Sun, 5 Jul 2026 02:52:21 +0800 Subject: [PATCH] Add Tiptap IME test page --- apps/web/src/styles/tiptap-ime-test.css | 182 ++++++++++++++ apps/web/src/tiptap-ime-test.tsx | 310 ++++++++++++++++++++++++ apps/web/tiptap-ime-test.html | 20 ++ apps/web/vite.config.ts | 3 +- 4 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/styles/tiptap-ime-test.css create mode 100644 apps/web/src/tiptap-ime-test.tsx create mode 100644 apps/web/tiptap-ime-test.html diff --git a/apps/web/src/styles/tiptap-ime-test.css b/apps/web/src/styles/tiptap-ime-test.css new file mode 100644 index 00000000..33c0fff9 --- /dev/null +++ b/apps/web/src/styles/tiptap-ime-test.css @@ -0,0 +1,182 @@ +:root { + color-scheme: light; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +html, +body, +#tiptap-ime-test-root { + min-height: 100%; +} + +body { + margin: 0; + background: #f8fafc; + color: #0f172a; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +textarea { + font: inherit; +} + +.tiptap-ime-page { + display: grid; + gap: 12px; + min-height: 100dvh; + padding: max(12px, env(safe-area-inset-top)) 12px max(16px, env(safe-area-inset-bottom)); +} + +.tiptap-ime-header, +.tiptap-ime-panel { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + box-shadow: 0 10px 28px rgb(15 23 42 / 0.05); +} + +.tiptap-ime-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px; +} + +.tiptap-ime-header h1 { + margin: 0; + font-size: 18px; + line-height: 1.3; +} + +.tiptap-ime-header p { + margin: 4px 0 0; + color: #64748b; + font-size: 13px; + line-height: 1.4; +} + +.tiptap-ime-header button, +.tiptap-ime-panel-title button, +.tiptap-ime-actions button { + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #0f172a; + font-weight: 700; + min-height: 34px; + padding: 6px 11px; + white-space: nowrap; +} + +.tiptap-ime-header button { + background: #0f172a; + color: #fff; +} + +.tiptap-ime-panel { + overflow: hidden; +} + +.tiptap-ime-panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + color: #334155; + font-size: 14px; + font-weight: 800; + padding: 8px 10px; +} + +.tiptap-ime-editor-wrap { + background: #fff; +} + +.tiptap-ime-editor { + min-height: 220px; + outline: none; + padding: 12px; + color: #0f172a; + font-size: 17px; + line-height: 1.7; + -webkit-user-select: text; + user-select: text; + caret-color: auto; + white-space: pre-wrap; +} + +.tiptap-ime-editor p { + margin: 0 0 0.75em; +} + +.tiptap-ime-editor img { + height: auto; + max-width: 100%; + border-radius: 8px; +} + +.tiptap-ime-editor p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + height: 0; + color: #94a3b8; + pointer-events: none; +} + +.tiptap-ime-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + border-top: 1px solid #e2e8f0; + padding: 10px; +} + +.tiptap-ime-panel textarea { + display: block; + width: 100%; + min-height: 140px; + resize: vertical; + border: 0; + outline: none; + padding: 12px; + color: #0f172a; + font-size: 17px; + line-height: 1.7; +} + +.tiptap-ime-panel textarea::placeholder { + color: #94a3b8; +} + +.tiptap-ime-diagnostics pre { + margin: 0; + overflow-x: auto; + padding: 10px; + color: #334155; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + +.tiptap-ime-log-list { + display: grid; + gap: 5px; + max-height: 260px; + overflow: auto; + padding: 10px; + color: #334155; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; +} diff --git a/apps/web/src/tiptap-ime-test.tsx b/apps/web/src/tiptap-ime-test.tsx new file mode 100644 index 00000000..ad8f2640 --- /dev/null +++ b/apps/web/src/tiptap-ime-test.tsx @@ -0,0 +1,310 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import Placeholder from "@tiptap/extension-placeholder"; +import "./styles/tiptap-ime-test.css"; + +type LogEntry = { + id: number; + event: string; + activeElement: string; + textLength: number; + time: string; + inputType?: string; + data?: string | null; + key?: string; + isComposing?: boolean; +}; + +let nextLogId = 1; + +const nowTime = () => new Date().toLocaleTimeString("zh-CN", { hour12: false }); + +const activeElementLabel = () => { + const element = document.activeElement; + if (!element) { + return "none"; + } + + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const className = + element instanceof HTMLElement && element.className + ? `.${String(element.className).trim().split(/\s+/).slice(0, 2).join(".")}` + : ""; + const role = element.getAttribute("role"); + + return `${tag}${id}${className}${role ? `[role=${role}]` : ""}`; +}; + +const eventPayload = (event: Event) => { + if (event instanceof InputEvent) { + return { + inputType: event.inputType, + data: event.data, + isComposing: event.isComposing, + }; + } + + if (event instanceof KeyboardEvent) { + return { + key: event.key, + isComposing: event.isComposing, + }; + } + + if (event instanceof CompositionEvent) { + return { + data: event.data, + }; + } + + return {}; +}; + +const TiptapImeTestApp = () => { + const [logs, setLogs] = useState([]); + const [plainValue, setPlainValue] = useState(""); + const [copied, setCopied] = useState(false); + + const editor = useEditor({ + extensions: [ + StarterKit, + Image.configure({ + allowBase64: false, + inline: false, + }), + Placeholder.configure({ + placeholder: "在这里测试豆包输入法...", + }), + ], + content: { + type: "doc", + content: [{ type: "paragraph" }], + }, + editorProps: { + attributes: { + class: "tiptap-ime-editor", + autocapitalize: "sentences", + autocomplete: "on", + autocorrect: "on", + inputmode: "text", + spellcheck: "true", + }, + }, + }); + + const pushLog = useCallback( + (eventName: string, event?: Event) => { + const textLength = editor?.getText().length ?? 0; + const entry: LogEntry = { + id: nextLogId++, + event: eventName, + activeElement: activeElementLabel(), + textLength, + time: nowTime(), + ...(event ? eventPayload(event) : {}), + }; + + setLogs((current) => [entry, ...current].slice(0, 80)); + }, + [editor] + ); + + useEffect(() => { + if (!editor) { + return; + } + + const handleUpdate = () => pushLog("tiptap-update"); + const handleFocus = () => pushLog("tiptap-focus"); + const handleBlur = () => pushLog("tiptap-blur"); + + editor.on("update", handleUpdate); + editor.on("focus", handleFocus); + editor.on("blur", handleBlur); + pushLog("tiptap-ready"); + + return () => { + editor.off("update", handleUpdate); + editor.off("focus", handleFocus); + editor.off("blur", handleBlur); + }; + }, [editor, pushLog]); + + useEffect(() => { + if (!editor) { + return; + } + + const dom = editor.view.dom; + const events = [ + "click", + "focus", + "blur", + "keydown", + "keyup", + "beforeinput", + "input", + "compositionstart", + "compositionupdate", + "compositionend", + ] as const; + + const handlers = new Map(); + for (const eventName of events) { + const handler: EventListener = (event) => pushLog(eventName, event); + handlers.set(eventName, handler); + dom.addEventListener(eventName, handler); + } + + return () => { + for (const [eventName, handler] of handlers) { + dom.removeEventListener(eventName, handler); + } + }; + }, [editor, pushLog]); + + const diagnostics = useMemo(() => { + const dom = editor?.view.dom; + + return { + userAgent: navigator.userAgent, + activeElement: activeElementLabel(), + tiptapFocused: editor?.isFocused ?? false, + tiptapTextLength: editor?.getText().length ?? 0, + tiptapHTMLLength: editor?.getHTML().length ?? 0, + tiptapRole: dom?.getAttribute("role") ?? null, + tiptapContentEditable: dom?.getAttribute("contenteditable") ?? null, + textareaLength: plainValue.length, + }; + }, [editor, logs, plainValue.length]); + + const copyDiagnostics = async () => { + const body = [ + `url=${location.href}`, + `userAgent=${diagnostics.userAgent}`, + `activeElement=${diagnostics.activeElement}`, + `tiptapFocused=${diagnostics.tiptapFocused}`, + `tiptapTextLength=${diagnostics.tiptapTextLength}`, + `tiptapHTMLLength=${diagnostics.tiptapHTMLLength}`, + `tiptapRole=${diagnostics.tiptapRole}`, + `tiptapContentEditable=${diagnostics.tiptapContentEditable}`, + `textareaLength=${diagnostics.textareaLength}`, + "", + ...logs.map((entry) => + [ + entry.time, + entry.event, + `active=${entry.activeElement}`, + `len=${entry.textLength}`, + entry.inputType ? `type=${entry.inputType}` : "", + entry.data ? `data=${JSON.stringify(entry.data)}` : "", + entry.key ? `key=${entry.key}` : "", + entry.isComposing !== undefined ? `composing=${entry.isComposing}` : "", + ] + .filter(Boolean) + .join(" ") + ), + ].join("\n"); + + await navigator.clipboard.writeText(body); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + return ( +
+
+
+

Tiptap IME 测试

+

主编辑区使用 Tiptap / ProseMirror / contenteditable。

+
+ +
+ +
+
+ Tiptap 编辑区 + +
+
+ +
+
+ + + +
+
+ +
+
+ 原生 textarea 对照 +
+