Add Tiptap IME test page
This commit is contained in:
1 parent
8447b6d4fc
commit
abfaeb3d85
4 files changed
+514
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user