Add Tiptap IME test page

This commit is contained in:
yoma committed 2026-07-05 02:52:21 +08:00
1 parent 8447b6d4fc
commit abfaeb3d85
4 files changed
+514 -1

No files matched your search

+182
View File
@@ -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;
}
+310
View File
@@ -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<LogEntry[]>([]);
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<string, EventListener>();
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 (
<main className="tiptap-ime-page">
<header className="tiptap-ime-header">
<div>
<h1>Tiptap IME 测试</h1>
<p>主编辑区使用 Tiptap / ProseMirror / contenteditable。</p>
</div>
<button type="button" onClick={() => void copyDiagnostics()}>
{copied ? "已复制" : "复制日志"}
</button>
</header>
<section className="tiptap-ime-panel">
<div className="tiptap-ime-panel-title">
<span>Tiptap 编辑区</span>
<button type="button" onClick={() => editor?.commands.focus("end")}>
聚焦
</button>
</div>
<div className="tiptap-ime-editor-wrap">
<EditorContent editor={editor} />
</div>
<div className="tiptap-ime-actions">
<button type="button" onClick={() => editor?.chain().focus().clearContent().run()}>
清空
</button>
<button type="button" onClick={() => editor?.chain().focus().insertContent("测试文字").run()}>
插入测试文字
</button>
<button type="button" onClick={() => editor?.chain().focus().setImage({ src: "https://edgeever.org/favicon.svg", alt: "测试图片" }).run()}>
插入图片
</button>
</div>
</section>
<section className="tiptap-ime-panel">
<div className="tiptap-ime-panel-title">
<span>原生 textarea 对照</span>
</div>
<textarea
value={plainValue}
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
enterKeyHint="enter"
inputMode="text"
spellCheck
placeholder="这里是原生 textarea,对照测试输入法..."
onChange={(event) => {
setPlainValue(event.target.value);
pushLog("textarea-change");
}}
/>
</section>
<section className="tiptap-ime-panel tiptap-ime-diagnostics">
<div className="tiptap-ime-panel-title">
<span>诊断</span>
</div>
<pre>{JSON.stringify(diagnostics, null, 2)}</pre>
</section>
<section className="tiptap-ime-panel tiptap-ime-log">
<div className="tiptap-ime-panel-title">
<span>事件日志</span>
</div>
<div className="tiptap-ime-log-list">
{logs.map((entry) => (
<div key={entry.id}>
{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}` : ""}
</div>
))}
</div>
</section>
</main>
);
};
const root = document.getElementById("tiptap-ime-test-root");
if (!root) {
throw new Error("Tiptap IME test root not found");
}
createRoot(root).render(
<React.StrictMode>
<TiptapImeTestApp />
</React.StrictMode>
);
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="format-detection" content="telephone=no" />
<title>Tiptap IME 测试 - EdgeEver</title>
</head>
<body>
<div id="tiptap-ime-test-root"></div>
<script type="module" src="/src/tiptap-ime-test.tsx"></script>
</body>
</html>
+2 -1
View File
@@ -58,7 +58,7 @@ export default defineConfig({
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,webp,woff2}"],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//, /^\/mcp\//, /^\/mobile-edit\.html$/],
navigateFallbackDenylist: [/^\/api\//, /^\/mcp\//, /^\/mobile-edit\.html$/, /^\/tiptap-ime-test\.html$/],
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith("/api/") || url.pathname.startsWith("/mcp/"),
@@ -92,6 +92,7 @@ export default defineConfig({
input: {
app: fileURLToPath(new URL("./index.html", import.meta.url)),
"mobile-edit": fileURLToPath(new URL("./mobile-edit.html", import.meta.url)),
"tiptap-ime-test": fileURLToPath(new URL("./tiptap-ime-test.html", import.meta.url)),
},
output: {
codeSplitting: {