From f7c2f8f53e106a07bb72349007abbe53bee7ce18 Mon Sep 17 00:00:00 2001 From: cniucheng Date: Wed, 25 Feb 2026 14:57:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E7=9A=84=E5=9D=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/editor-checklist.md | 19 + package.json | 1 + prisma/dev.db | Bin 196608 -> 196608 bytes scripts/run-editor-block-ops-tests.mjs | 44 + src/app/globals.css | 12 + src/app/settings/page.tsx | 93 +- src/components/editor.tsx | 1106 +++++++++++++++++++++++- src/lib/editor-block-ops.test.ts | 57 ++ src/lib/editor-block-ops.ts | 47 + src/lib/page-history.ts | 185 +++- src/lib/settings-store.ts | 17 + 11 files changed, 1565 insertions(+), 16 deletions(-) create mode 100644 docs/editor-checklist.md create mode 100644 scripts/run-editor-block-ops-tests.mjs create mode 100644 src/lib/editor-block-ops.test.ts create mode 100644 src/lib/editor-block-ops.ts diff --git a/docs/editor-checklist.md b/docs/editor-checklist.md new file mode 100644 index 0000000..b8de6a2 --- /dev/null +++ b/docs/editor-checklist.md @@ -0,0 +1,19 @@ +# Editor Quick Checklist + +1. Select 2-3 paragraphs, open block menu, convert to code block. +Expected: merged into 1 code block, selection remains on that block. + +2. Select 2-3 contiguous blocks, click move up/down. +Expected: all selected blocks move together in one step. + +3. Select 2-3 contiguous blocks, drag using the block handle. +Expected: drag preview shows "will move N blocks", all blocks move together. + +4. Select multiple blocks, run copy/delete/duplicate from block menu. +Expected: operation applies to all selected blocks. + +5. After each multi-block action, press `Ctrl/Cmd + Z` once. +Expected: the full action is reverted in one undo step. + +6. Open block menu and expand "查看快捷键". +Expected: shortcuts are visible and menu remains usable. diff --git a/package.json b/package.json index 4211e73..f83ff16 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "node --no-warnings --experimental-strip-types scripts/run-editor-block-ops-tests.mjs", "check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit" }, "dependencies": { diff --git a/prisma/dev.db b/prisma/dev.db index b4c705153a2e2959ac4627d3c075ee214ef5a8bc..05f5999cb0cfedc7a49f732b75cbcd69c6eb5298 100644 GIT binary patch delta 1993 zcmc&#eQZ-z6o2QucI(=;D~_U;xLSQt3ZfMo<{a;eoCO;$-}){Ly#OkMA?)o)@xcJ@iyEes?upPEvg z-eo(!mFdf(2e%(vs4nw3@&aP+?U||o6RHPSC~xuN?NKp#f~*o>y`3J_WezQi3g5k* z5f$DTnnTMwA%)KCA*OZ!4vOsqBgE>viQ?2d&9t%;9#;-BPVkoJdY*T040t^g)|TgL z#d~+MK`UAta@n)6A&C~Og|YwQdg5*dbNc@7j>oie7~yYmRyUfl%+q|}o)szIFnrty z+0R{4;r11KN}}g|`g?&GK03X5?)_F!iTmoWoC4koCNaKgzT8bSq991_t9VGSVg%sG zSph4lC>1wRb_>|0Z&GmuM8#R-uo&rwPOwwU�H0Y?!Z@CP_mRa3;!LlcZ^OoIboE zkdEC^2P&L`A1QV^aRt^>Z3ccFbsu*Q=L31x<3vLw{}WcmT1|?Qi1_V!i*&<^X$ERJ zhnH2=U!8fSD%h1`dheLh9bt{vU%X+{bN+IcR;95y5Pl*#hZN>C{Cu#;{#iEB8^`8px-zt2iVbx`vl@;UX{^;y&Q@2G>);oji^4 zfiq#b-|Mk1@H|`UD=jbe`^aAmR;6ha@j3<3Dp*ZVKde!_*{(YF(E8f(PuLKC5M`LC-W6 zY1L-9j|ttc?d?5ur01RBjnK}%*4JrXBTONu6VpartK1pouH*V1>Zz;mtvf9{bg9uL zE(@C}Ed&|T<#yDmz%RY|B6!BbBW$^LxaFglsKQO4vOYTUu}n@d^3|j*;=>UDtIlBL zFt(733&s|Pf_)&}11wR;eVCXm)8ge+8e_oKRN;fkG;SGV)lo(Rjo>YHh zt2#O_tas&#Xs4V$)JjDkV=o_eB43nZ`@fTsBM(K6c9=N-m! z=8cTe-*Q=owh|yNq&+?`)8}r8)ts$JVJsaS{ZKEpZH7*sY#~-6mbpH5(h~PZk3EMD zZ)d*%YX_Rh(ScT8u2NA4J|PxLv#GxW_t8K%&Vc|qHE0*#Z%M|y0Cm)}YA?Qk8>sC9 zE~hVFfKuj>+5k({?6zK)McW&|idhcvi_@v%FK3^o{vWYZuG(KqCD%DqGTz62rR15S XuBqTZ>U)b*h5C^*HQFDWM$;CHf@uA%Kb{dh0u{ho7q z&-0%1xms;5zB^#jYruqb!#m@I6+BT8>|41Y!0GMH1e3kO~TCe4*L2cwg^b~XShrd zT6=J%APn{3enD{eVu>Iq%i&o;aFxSa#XalBt@SLFp>mi_J8dl1^WS1=FnqFagTtYy zV=rN$pxxLP=5equ%wugo+60*Q@|585_SfC};L)9FzPG2G8rUWXXKNsr&Y#6ZX)w0q z15#5Y9ueUY@Os@Jf~R|8Lbk6gT2ZX;F6*B7O%L6t4j+v6+&voU*?M=Eq^&vnj}su) zf{4c7#8@)FkDKW=7aU~#SvSE7O862Y$aWZ~^G$AeUK#1^{hjeIqQ<8&o!%-#6^&Oz zK3yn7HF2xZz_T-T1Z_{oj0n!`45Uf52o^?h=0M5Ck0c(3_G8MEWymnb8&C{{U*%e<_xL0I81v>D%Q~l z5$qZtWx^|=U1k>4#6D;wc&lSsU}QI2oh~bw1z2A?E}1!H+PrCgPVTxF9L_CfBinK`--z#txr!>P z-HZl}Kl>c)Z)US3P`al^Iy=Yu_ETaXEa3|VU^h3(GtYRwCXtRXkcQf~;wV4X1u-fd zKJBxES}7ZNQK2Zd-mRkq2?P^9%OfNb@oS*v~6M5|# z%mx}ydqddWIRhgd0Ou z@zq)uU~4}nki3+68oJDOm8EE)^zSe?1Zg^h$xLUc8cL=ri5*z3gb*ooXcaCdV>zyt zYex_HMqfSp(}6d%t9@M{(UQ)mCFCZ@~3ZKB)+kkatJn9*JxqBc2ugopm%riPgaEtpy_; zQ3n6*)iSiO5>8NI5?d@P{8SbAv5$IuBZr!Yndvy68gC^^+KTdK`AjHQ!yHeAePy>{ zz^n4vP;3Vg-)eytrKH8)6ytR}ftL&*s3YN~NIVKRz3yLuS6{_(ko3rBDfruf&jf?( zh>$Vs)7VJkl@`p5;4R585T)F`E7*yYYLQZ{@O8(iI~397dPPj!43Sg{GSAWGIVnH( r?PL9Kq_b assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC"), + () => assert.equal(computeSpanTargetStart(6, 2, 3, 3), 2), + () => assert.equal(computeSpanTargetStart(6, 3, 4, 1), 1), + () => assert.equal(computeSpanTargetStart(6, 1, 2, 5), 3), + () => { + const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 2, 3, 1); + assert.equal(moved.changed, true); + assert.equal(moved.targetStart, 1); + assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]); + }, + () => { + const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 2, 5); + assert.equal(moved.changed, true); + assert.equal(moved.targetStart, 3); + assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]); + }, + () => { + const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 3, 2); + assert.equal(moved.changed, false); + assert.equal(moved.targetStart, 1); + assert.deepEqual(moved.reordered, ["A", "B", "C", "D", "E"]); + }, + () => { + assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前"); + assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前"); + assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾"); + }, +]; + +for (const [index, run] of cases.entries()) { + run(); + console.log(`ok ${index + 1}`); +} + +console.log(`All ${cases.length} editor block operation checks passed.`); diff --git a/src/app/globals.css b/src/app/globals.css index e7af1ed..a322a05 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -353,3 +353,15 @@ ul[data-type="taskList"], /* Let pre handle the background */ } +.ProseMirror > .block-drag-active { + border-radius: 0.5rem; + background: hsl(var(--accent) / 0.45); + box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.7); +} + +.ProseMirror > .block-selected { + border-radius: 0.5rem; + background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25)); + box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28); +} + diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 29f004b..5b8c5f1 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState } from "react"; -import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Lock, Save, Sparkles, Type, Upload, Download, Loader2, History, Trash2 } from "lucide-react"; import { ResizableSidebar } from "@/components/sidebar"; import { PromptManagement } from "@/components/settings/prompt-management"; import { ImportProvider } from "@/components/import-context"; import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store"; import { useConfirm } from "@/components/confirm-provider"; +import { cleanupAllPageHistories, estimateAllPageHistoriesUsage, trimAllPageHistoriesToRecent } from "@/lib/page-history"; export default function SettingsPage() { const confirm = useConfirm(); @@ -21,6 +22,8 @@ export default function SettingsPage() { setTableLineHeight, timezone, setTimezone, + localHistory, + setLocalHistory, aiConfig, setAIConfig, } = useSettingsStore(); @@ -30,8 +33,15 @@ export default function SettingsPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [isExporting, setIsExporting] = useState(false); const [isRestoring, setIsRestoring] = useState(false); + const [historyMsg, setHistoryMsg] = useState(null); + const [trimRecentCount, setTrimRecentCount] = useState(10); + const [historyStats, setHistoryStats] = useState({ keys: 0, snapshots: 0, bytes: 0 }); const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); + useEffect(() => { + setHistoryStats(estimateAllPageHistoriesUsage()); + }, []); + const handlePasswordChange = async (e: React.FormEvent) => { e.preventDefault(); setMsg(null); @@ -151,6 +161,85 @@ export default function SettingsPage() { +
+
+ +

本地历史策略

+
+

+ 控制浏览器本地历史快照占用空间。保存后会在后续写入时自动清理,也可立即手动清理一次。 +

+
+ + +
+
+
本地历史键: {historyStats.keys}
+
快照总数: {historyStats.snapshots}
+
估算占用: {(historyStats.bytes / 1024).toFixed(1)} KB
+
+
+ +
+ setTrimRecentCount(Math.max(1, Math.min(200, parseInt(e.target.value || "1", 10))))} + className="ui-input h-9 w-24" + /> + +
+ {historyMsg && {historyMsg}} +
+
+
diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 78f98f8..6f7afee 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -4,7 +4,24 @@ import { useEditor, EditorContent, ReactNodeViewRenderer, type Editor as TiptapE import StarterKit from "@tiptap/starter-kit"; import { useEffect, useState, useRef } from "react"; import { AIAssist } from "./ai-assist"; -import { Sparkles } from "lucide-react"; +import { + Sparkles, + Plus, + GripVertical, + ChevronUp, + ChevronDown, + MoreHorizontal, + Copy, + Trash2, + Type, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Quote, + Code, +} from "lucide-react"; import { Toolbar } from "./editor/toolbar"; import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; @@ -30,6 +47,9 @@ import Youtube from "@tiptap/extension-youtube"; import TextAlign from "@tiptap/extension-text-align"; import Gapcursor from "@tiptap/extension-gapcursor"; import { Markdown } from "tiptap-markdown"; +import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops"; interface EditorProps { content: string; @@ -61,10 +81,545 @@ function resolveWikiLinkAtPos(doc: TiptapEditor["state"]["doc"], pos: number): s return null; } +function moveTopLevelBlockByIndex(editor: TiptapEditor, index: number, direction: "up" | "down"): number { + const { doc } = editor.state; + if (index < 0 || index >= doc.childCount) return index; + if (direction === "up" && index === 0) return index; + if (direction === "down" && index >= doc.childCount - 1) return index; + + const targetIndex = direction === "up" ? index - 1 : index + 1; + const currentNode = doc.child(index); + const targetNode = doc.child(targetIndex); + + let currentStart = 0; + for (let i = 0; i < index; i += 1) { + currentStart += doc.child(i).nodeSize; + } + const currentEnd = currentStart + currentNode.nodeSize; + + let targetStart = 0; + for (let i = 0; i < targetIndex; i += 1) { + targetStart += doc.child(i).nodeSize; + } + const targetEnd = targetStart + targetNode.nodeSize; + + const rangeStart = Math.min(currentStart, targetStart); + const rangeEnd = Math.max(currentEnd, targetEnd); + const ordered = direction === "up" ? [currentNode, targetNode] : [targetNode, currentNode]; + + const tr = editor.state.tr.replaceWith(rangeStart, rangeEnd, Fragment.fromArray(ordered)); + const nextPos = direction === "up" ? targetStart : currentStart + targetNode.nodeSize; + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(1, nextPos + 1)))); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return targetIndex; +} + +function moveTopLevelBlockSpanToInsertionIndex( + editor: TiptapEditor, + sourceStartIndex: number, + sourceEndIndex: number, + insertionIndex: number +): number { + const { doc } = editor.state; + if (sourceStartIndex < 0 || sourceStartIndex >= doc.childCount) return sourceStartIndex; + if (sourceEndIndex < sourceStartIndex || sourceEndIndex >= doc.childCount) return sourceStartIndex; + + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const { reordered, targetStart, changed } = moveContiguousSpan(nodes, sourceStartIndex, sourceEndIndex, insertionIndex); + if (!changed) return sourceStartIndex; + const movedCount = sourceEndIndex - sourceStartIndex + 1; + + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(reordered)); + setSelectionToTopLevelBlockSpan(tr, targetStart, targetStart + movedCount - 1); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return targetStart; +} + +function getTopLevelBlockRange(doc: TiptapEditor["state"]["doc"], index: number): { start: number; end: number } | null { + if (index < 0 || index >= doc.childCount) return null; + let start = 0; + for (let i = 0; i < index; i += 1) { + start += doc.child(i).nodeSize; + } + const end = start + doc.child(index).nodeSize; + return { start, end }; +} + +function getTopLevelBlockSelectionRange( + doc: TiptapEditor["state"]["doc"], + startIndex: number, + endIndex: number +): { from: number; to: number } | null { + if (startIndex < 0 || endIndex < startIndex || endIndex >= doc.childCount) return null; + + let startPos = 0; + for (let i = 0; i < startIndex; i += 1) { + startPos += doc.child(i).nodeSize; + } + + let endPos = startPos; + for (let i = startIndex; i <= endIndex; i += 1) { + endPos += doc.child(i).nodeSize; + } + + const from = Math.max(1, startPos + 1); + const to = Math.max(from, endPos - 1); + return { from, to }; +} + +function setSelectionToTopLevelBlockSpan(tr: Parameters[0], startIndex: number, endIndex: number): void { + const range = getTopLevelBlockSelectionRange(tr.doc, startIndex, endIndex); + if (!range) return; + tr.setSelection(TextSelection.create(tr.doc, range.from, range.to)); +} + +function runOnTopLevelBlock(editor: TiptapEditor, index: number, action: () => boolean): boolean { + const range = getTopLevelBlockRange(editor.state.doc, index); + if (!range) return false; + const from = Math.max(1, range.start + 1); + const to = Math.max(from, range.end - 1); + const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.tr.doc, from, to)); + editor.view.dispatch(tr); + editor.commands.focus(); + return action(); +} + +function getSelectedTopLevelBlockIndices(editor: TiptapEditor): number[] { + const { doc, selection } = editor.state; + if (doc.childCount === 0) return []; + + const from = Math.min(selection.from, selection.to); + const to = Math.max(selection.from, selection.to); + const indices: number[] = []; + + let cursor = 0; + for (let i = 0; i < doc.childCount; i += 1) { + const node = doc.child(i); + const start = cursor; + const end = cursor + node.nodeSize; + const overlap = to > start && from < end; + if (overlap) indices.push(i); + cursor = end; + } + + if (indices.length > 0) return indices; + const currentIndex = getCurrentTopLevelBlockIndex(editor); + return currentIndex === null ? [] : [currentIndex]; +} + +function resolveTargetTopLevelBlockIndices(editor: TiptapEditor, preferredIndex?: number | null): number[] { + const selected = getSelectedTopLevelBlockIndices(editor); + const hasRangeSelection = editor.state.selection.from !== editor.state.selection.to; + // A text range spanning multiple top-level blocks should always stay a multi-block target. + if (hasRangeSelection && selected.length > 1) return selected; + if (preferredIndex === null || preferredIndex === undefined) return selected; + if (preferredIndex < 0 || preferredIndex >= editor.state.doc.childCount) return selected; + if (selected.length > 1 && selected.includes(preferredIndex)) return selected; + return [preferredIndex]; +} + +function convertTopLevelBlock(editor: TiptapEditor, index: number, type: string): boolean { + return runOnTopLevelBlock(editor, index, () => { + const node = editor.state.selection.$from.node(1); + switch (type) { + case "paragraph": + return editor.chain().focus().setParagraph().run(); + case "heading1": + if (node.type.name === "heading" && node.attrs.level === 1) { + return editor.chain().focus().setParagraph().run(); + } + return editor.chain().focus().setNode("heading", { level: 1 }).run(); + case "heading2": + if (node.type.name === "heading" && node.attrs.level === 2) { + return editor.chain().focus().setParagraph().run(); + } + return editor.chain().focus().setNode("heading", { level: 2 }).run(); + case "heading3": + if (node.type.name === "heading" && node.attrs.level === 3) { + return editor.chain().focus().setParagraph().run(); + } + return editor.chain().focus().setNode("heading", { level: 3 }).run(); + case "bulletList": + return editor.chain().focus().toggleBulletList().run(); + case "orderedList": + return editor.chain().focus().toggleOrderedList().run(); + case "blockquote": + return editor.chain().focus().toggleBlockquote().run(); + case "codeBlock": + if (node.type.name === "codeBlock") { + return editor.chain().focus().setParagraph().run(); + } + return editor.chain().focus().setNode("codeBlock").run(); + default: + return false; + } + }); +} + +function createTextOrNull(schema: Schema, text: string): ProseMirrorNode | null { + return text ? schema.text(text) : null; +} + +function createParagraphNode(schema: Schema, text: string): ProseMirrorNode { + return schema.nodes.paragraph.create(null, createTextOrNull(schema, text) ?? undefined); +} + +function createListItemNode(schema: Schema, text: string): ProseMirrorNode { + return schema.nodes.listItem.create(null, createParagraphNode(schema, text)); +} + +function extractListItemsFromTopLevelNode(schema: Schema, node: ProseMirrorNode): ProseMirrorNode[] { + if (node.type.name === "bulletList" || node.type.name === "orderedList") { + const items: ProseMirrorNode[] = []; + for (let i = 0; i < node.childCount; i += 1) { + const listItem = node.child(i); + items.push(createListItemNode(schema, listItem.textContent || "")); + } + return items; + } + return [createListItemNode(schema, node.textContent || "")]; +} + +function createConvertedNodeByType( + schema: Schema, + sourceNode: ProseMirrorNode, + type: string, + text: string +): ProseMirrorNode { + switch (type) { + case "paragraph": + return createParagraphNode(schema, text); + case "heading1": + if (sourceNode.type.name === "heading" && sourceNode.attrs.level === 1) return createParagraphNode(schema, text); + return schema.nodes.heading.create({ level: 1 }, createTextOrNull(schema, text) ?? undefined); + case "heading2": + if (sourceNode.type.name === "heading" && sourceNode.attrs.level === 2) return createParagraphNode(schema, text); + return schema.nodes.heading.create({ level: 2 }, createTextOrNull(schema, text) ?? undefined); + case "heading3": + if (sourceNode.type.name === "heading" && sourceNode.attrs.level === 3) return createParagraphNode(schema, text); + return schema.nodes.heading.create({ level: 3 }, createTextOrNull(schema, text) ?? undefined); + case "blockquote": + if (sourceNode.type.name === "blockquote") return createParagraphNode(schema, text); + return schema.nodes.blockquote.create(null, createParagraphNode(schema, text)); + default: + return sourceNode; + } +} + +function convertSelectedTopLevelBlocks(editor: TiptapEditor, type: string, preferredIndex?: number | null): boolean { + const indices = resolveTargetTopLevelBlockIndices(editor, preferredIndex); + if (indices.length === 0) return false; + + const start = indices[0]; + const end = indices[indices.length - 1]; + + if (indices.length > 1 && type === "codeBlock") { + const { doc, schema } = editor.state; + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const mergedText = mergeBlockTexts(nodes.slice(start, end + 1).map((node) => node.textContent || "")); + const mergedCodeBlock = schema.nodes.codeBlock.create(null, createTextOrNull(schema, mergedText) ?? undefined); + const transformed = [...nodes.slice(0, start), mergedCodeBlock, ...nodes.slice(end + 1)]; + + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(transformed)); + setSelectionToTopLevelBlockSpan(tr, start, start); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return true; + } + + if (indices.length > 1 && (type === "bulletList" || type === "orderedList")) { + const { doc, schema } = editor.state; + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const listItems = nodes.slice(start, end + 1).flatMap((node) => extractListItemsFromTopLevelNode(schema, node)); + const listNode = schema.nodes[type].create(null, listItems); + const transformed = [...nodes.slice(0, start), listNode, ...nodes.slice(end + 1)]; + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(transformed)); + setSelectionToTopLevelBlockSpan(tr, start, start); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return true; + } + + if (indices.length > 1 && (type === "paragraph" || type === "heading1" || type === "heading2" || type === "heading3" || type === "blockquote")) { + const { doc, schema } = editor.state; + const selectedSet = new Set(indices); + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const transformed = nodes.map((node, index) => { + if (!selectedSet.has(index)) return node; + return createConvertedNodeByType(schema, node, type, node.textContent || ""); + }); + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(transformed)); + const nextEnd = Math.min(start + indices.length - 1, tr.doc.childCount - 1); + setSelectionToTopLevelBlockSpan(tr, start, nextEnd); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return true; + } + + let ok = false; + // Process from tail to head so earlier transformations won't invalidate later indices. + for (let i = indices.length - 1; i >= 0; i -= 1) { + ok = convertTopLevelBlock(editor, indices[i], type) || ok; + } + + if (ok && indices.length > 1) { + const nextEnd = Math.min(start + indices.length - 1, editor.state.doc.childCount - 1); + const range = getTopLevelBlockSelectionRange(editor.state.doc, start, nextEnd); + if (range) { + const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, range.from, range.to)); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + } + } + return ok; +} + +function findScrollableParent(el: HTMLElement | null): HTMLElement | null { + let cursor: HTMLElement | null = el; + while (cursor) { + const style = window.getComputedStyle(cursor); + const canScroll = (style.overflowY === "auto" || style.overflowY === "scroll") && cursor.scrollHeight > cursor.clientHeight; + if (canScroll) return cursor; + cursor = cursor.parentElement; + } + return null; +} + +function getCurrentTopLevelBlockIndex(editor: TiptapEditor): number | null { + const { doc, selection } = editor.state; + const { $from } = selection; + if ($from.depth < 1) return null; + const index = $from.index(0); + return index >= 0 && index < doc.childCount ? index : null; +} + +function getTopLevelBlockElement(editor: TiptapEditor, index: number): HTMLElement | null { + const { doc } = editor.state; + if (index < 0 || index >= doc.childCount) return null; + + let pos = 0; + for (let i = 0; i < index; i += 1) { + pos += doc.child(i).nodeSize; + } + + try { + const domAt = editor.view.domAtPos(Math.max(1, pos + 1)); + let el: HTMLElement | null = domAt.node instanceof HTMLElement ? domAt.node : domAt.node.parentElement; + const root = editor.view.dom; + while (el && el.parentElement !== root) { + el = el.parentElement; + } + return el && el.parentElement === root ? el : null; + } catch { + return null; + } +} + +function applyTopLevelBlockSelectionClasses(editor: TiptapEditor, indices: number[]): void { + const root = editor.view.dom as HTMLElement; + const selected = new Set(indices); + const children = Array.from(root.children); + for (let i = 0; i < children.length; i += 1) { + const el = children[i]; + if (!(el instanceof HTMLElement)) continue; + el.classList.toggle("block-selected", selected.has(i)); + } +} + +function resolveInsertionIndex(editor: TiptapEditor, hostEl: HTMLElement, pointerY: number): { index: number; top: number } { + const { doc } = editor.state; + if (doc.childCount === 0) return { index: 0, top: 0 }; + + const hostRect = hostEl.getBoundingClientRect(); + const y = pointerY - hostRect.top; + + const tops: number[] = []; + const mids: number[] = []; + const bottoms: number[] = []; + for (let i = 0; i < doc.childCount; i += 1) { + const el = getTopLevelBlockElement(editor, i); + if (!el) continue; + const rect = el.getBoundingClientRect(); + const top = rect.top - hostRect.top; + const bottom = rect.bottom - hostRect.top; + tops.push(top); + mids.push(top + rect.height / 2); + bottoms.push(bottom); + } + + if (tops.length === 0) return { index: 0, top: 0 }; + + for (let i = 0; i < mids.length; i += 1) { + if (y < mids[i]) { + return { index: i, top: tops[i] }; + } + } + + return { index: mids.length, top: bottoms[bottoms.length - 1] }; +} + +function duplicateTopLevelBlockByIndex(editor: TiptapEditor, index: number): boolean { + const range = getTopLevelBlockRange(editor.state.doc, index); + if (!range) return false; + const node = editor.state.doc.child(index); + const tr = editor.state.tr.insert(range.end, node.copy(node.content)); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return true; +} + +function duplicateSelectedTopLevelBlocks(editor: TiptapEditor, preferredIndex?: number | null): number | null { + const indices = resolveTargetTopLevelBlockIndices(editor, preferredIndex); + if (indices.length === 0) return null; + + if (indices.length === 1) { + const ok = duplicateTopLevelBlockByIndex(editor, indices[0]); + return ok ? indices[0] + 1 : null; + } + + const { doc } = editor.state; + const start = indices[0]; + const end = indices[indices.length - 1]; + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const copied = nodes.slice(start, end + 1).map((node) => node.copy(node.content)); + const reordered = [...nodes.slice(0, end + 1), ...copied, ...nodes.slice(end + 1)]; + + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(reordered)); + const firstInsertedIndex = end + 1; + setSelectionToTopLevelBlockSpan(tr, firstInsertedIndex, firstInsertedIndex + (end - start)); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return firstInsertedIndex; +} + +function moveSelectedTopLevelBlocks(editor: TiptapEditor, direction: "up" | "down", preferredIndex?: number | null): number | null { + const indices = resolveTargetTopLevelBlockIndices(editor, preferredIndex); + if (indices.length === 0) return null; + + const start = indices[0]; + const end = indices[indices.length - 1]; + const { doc } = editor.state; + + if (direction === "up" && start === 0) return start; + if (direction === "down" && end >= doc.childCount - 1) return start; + + if (indices.length === 1) { + return moveTopLevelBlockByIndex(editor, start, direction); + } + + const nodes = Array.from({ length: doc.childCount }, (_, i) => doc.child(i)); + const selected = nodes.slice(start, end + 1); + let reordered: typeof nodes; + let nextStart = start; + + if (direction === "up") { + const before = nodes[start - 1]; + reordered = [...nodes.slice(0, start - 1), ...selected, before, ...nodes.slice(end + 1)]; + nextStart = start - 1; + } else { + const after = nodes[end + 1]; + reordered = [...nodes.slice(0, start), after, ...selected, ...nodes.slice(end + 2)]; + nextStart = start + 1; + } + + const tr = editor.state.tr.replaceWith(0, doc.content.size, Fragment.fromArray(reordered)); + setSelectionToTopLevelBlockSpan(tr, nextStart, nextStart + (end - start)); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return nextStart; +} + +async function copySelectedTopLevelBlocks(editor: TiptapEditor, preferredIndex?: number | null): Promise { + const indices = resolveTargetTopLevelBlockIndices(editor, preferredIndex); + if (indices.length === 0) return false; + const lines = indices.map((index) => editor.state.doc.child(index).textContent || ""); + await navigator.clipboard.writeText(lines.join("\n\n")); + return true; +} + +function deleteSelectedTopLevelBlocks(editor: TiptapEditor, preferredIndex?: number | null): number | null { + const indices = resolveTargetTopLevelBlockIndices(editor, preferredIndex); + if (indices.length === 0) return null; + + const { doc } = editor.state; + if (indices.length >= doc.childCount) { + editor.commands.setContent("

"); + editor.commands.focus(); + return 0; + } + + const tr = editor.state.tr; + for (let i = indices.length - 1; i >= 0; i -= 1) { + const range = getTopLevelBlockRange(tr.doc, indices[i]); + if (!range) continue; + tr.delete(range.start, range.end); + } + + const nextIndex = Math.max(0, Math.min(indices[0], tr.doc.childCount - 1)); + let cursorPos = 0; + for (let i = 0; i < nextIndex; i += 1) { + cursorPos += tr.doc.child(i).nodeSize; + } + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(1, cursorPos + 1)))); + editor.view.dispatch(tr.scrollIntoView()); + editor.commands.focus(); + return nextIndex; +} + export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, onOpenWikiLink, editable = true }: EditorProps) { const [showAI, setShowAI] = useState(false); const [isGenerating, setIsGenerating] = useState(false); + const [showBlockMenu, setShowBlockMenu] = useState(false); + const [blockMenuTop, setBlockMenuTop] = useState(0); + const [showBlockActionMenu, setShowBlockActionMenu] = useState(false); + const [showShortcutHelp, setShowShortcutHelp] = useState(false); + const [activeBlockIndex, setActiveBlockIndex] = useState(null); + const [actionToast, setActionToast] = useState(null); + const [dragPreview, setDragPreview] = useState<{ + visible: boolean; + sourceStartIndex: number; + sourceEndIndex: number; + targetInsertionIndex: number; + movedCount: number; + insertionText: string; + ghostTop: number; + ghostLeft: number; + ghostWidth: number; + ghostText: string; + indicatorTop: number; + }>({ + visible: false, + sourceStartIndex: -1, + sourceEndIndex: -1, + targetInsertionIndex: -1, + movedCount: 0, + insertionText: "", + ghostTop: 0, + ghostLeft: 0, + ghostWidth: 0, + ghostText: "", + indicatorTop: 0, + }); const abortControllerRef = useRef(null); + const contentWrapRef = useRef(null); + const blockActionMenuRef = useRef(null); + const showBlockActionMenuRef = useRef(false); + const actionToastTimerRef = useRef(null); + const dragStateRef = useRef<{ + dragging: boolean; + lastY: number; + lastPointerY: number; + rafId: number | null; + }>({ dragging: false, lastY: 0, lastPointerY: 0, rafId: null }); + const dragScrollRef = useRef(null); + const dragPreviewRef = useRef<{ + sourceStartIndex: number; + sourceEndIndex: number; + targetInsertionIndex: number; + }>({ sourceStartIndex: -1, sourceEndIndex: -1, targetInsertionIndex: -1 }); + const activeBlockIndexRef = useRef(null); const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore(); const editor = useEditor({ @@ -151,6 +706,31 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${lineHeight}; --table-line-height: ${tableLineHeight};`, spellcheck: "false", }, + handleKeyDown: (_view, event) => { + if (!editable) return false; + + const editorInstance = editor; + if (!editorInstance) return false; + + // Alt + Shift + ArrowUp / ArrowDown: move current block. + if (event.altKey && event.shiftKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) { + event.preventDefault(); + const direction = event.key === "ArrowUp" ? "up" : "down"; + const next = moveSelectedTopLevelBlocks(editorInstance, direction); + if (next !== null) setActiveBlockIndex(next); + return true; + } + + // Ctrl/Cmd + Shift + D: duplicate current block. + if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "d") { + event.preventDefault(); + const next = duplicateSelectedTopLevelBlocks(editorInstance); + if (next !== null) setActiveBlockIndex(next); + return true; + } + + return false; + }, handleClick: (view, pos, event) => { if (!onOpenWikiLink) return false; const pointerEvent = event as MouseEvent; @@ -174,6 +754,137 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, } }, [editor, editable]); + useEffect(() => { + const dragState = dragStateRef.current; + return () => { + if (dragState.rafId) { + cancelAnimationFrame(dragState.rafId); + } + window.onpointermove = null; + window.onpointerup = null; + }; + }, []); + + useEffect(() => { + activeBlockIndexRef.current = activeBlockIndex; + }, [activeBlockIndex]); + + useEffect(() => { + showBlockActionMenuRef.current = showBlockActionMenu; + }, [showBlockActionMenu]); + + useEffect(() => { + return () => { + if (actionToastTimerRef.current) { + window.clearTimeout(actionToastTimerRef.current); + } + }; + }, []); + + useEffect(() => { + dragPreviewRef.current = { + sourceStartIndex: dragPreview.sourceStartIndex, + sourceEndIndex: dragPreview.sourceEndIndex, + targetInsertionIndex: dragPreview.targetInsertionIndex, + }; + }, [dragPreview.sourceStartIndex, dragPreview.sourceEndIndex, dragPreview.targetInsertionIndex]); + + const stopHandleDrag = () => { + dragStateRef.current.dragging = false; + dragStateRef.current.lastY = 0; + dragStateRef.current.lastPointerY = 0; + setDragPreview((prev) => ({ ...prev, visible: false })); + dragScrollRef.current = null; + if (dragStateRef.current.rafId) { + cancelAnimationFrame(dragStateRef.current.rafId); + dragStateRef.current.rafId = null; + } + window.onpointermove = null; + window.onpointerup = null; + }; + + const startHandleDrag = (startY: number) => { + if (!editor || !contentWrapRef.current) return; + const tiptap = editor; + const currentIndex = activeBlockIndexRef.current ?? getCurrentTopLevelBlockIndex(tiptap); + if (currentIndex === null) return; + const indices = resolveTargetTopLevelBlockIndices(tiptap, currentIndex); + if (indices.length === 0) return; + const sourceStart = indices[0]; + const sourceEnd = indices[indices.length - 1]; + const blockEl = getTopLevelBlockElement(tiptap, sourceStart); + if (!blockEl) return; + const blockEndEl = getTopLevelBlockElement(tiptap, sourceEnd); + if (!blockEndEl) return; + const hostRect = contentWrapRef.current.getBoundingClientRect(); + const blockRect = blockEl.getBoundingClientRect(); + const blockEndRect = blockEndEl.getBoundingClientRect(); + dragScrollRef.current = findScrollableParent(contentWrapRef.current); + const movedCount = sourceEnd - sourceStart + 1; + + dragStateRef.current.dragging = true; + dragStateRef.current.lastY = startY; + dragStateRef.current.lastPointerY = startY; + activeBlockIndexRef.current = sourceStart; + setActiveBlockIndex(sourceStart); + setDragPreview({ + visible: true, + sourceStartIndex: sourceStart, + sourceEndIndex: sourceEnd, + targetInsertionIndex: sourceEnd + 1, + movedCount, + insertionText: buildDropIndicatorText(sourceEnd + 1, tiptap.state.doc.childCount), + ghostTop: Math.max(0, blockRect.top - hostRect.top), + ghostLeft: 0, + ghostWidth: Math.max(180, blockRect.width), + ghostText: + indices.length > 1 + ? `${(tiptap.state.doc.child(sourceStart).textContent || "空块").slice(0, 80)} 等 ${indices.length} 个块` + : (tiptap.state.doc.child(sourceStart).textContent || "空块").slice(0, 140), + indicatorTop: Math.max(0, blockEndRect.bottom - hostRect.top), + }); + + window.onpointermove = (ev: PointerEvent) => { + if (!dragStateRef.current.dragging) return; + if (!contentWrapRef.current) return; + dragStateRef.current.lastPointerY = ev.clientY; + const host = contentWrapRef.current; + const hostBounds = host.getBoundingClientRect(); + + const scroller = dragScrollRef.current; + if (scroller) { + const rect = scroller.getBoundingClientRect(); + const edge = 56; + const maxStep = 22; + if (ev.clientY < rect.top + edge) { + const ratio = (rect.top + edge - ev.clientY) / edge; + scroller.scrollTop -= Math.ceil(maxStep * Math.min(1, Math.max(0, ratio))); + } else if (ev.clientY > rect.bottom - edge) { + const ratio = (ev.clientY - (rect.bottom - edge)) / edge; + scroller.scrollTop += Math.ceil(maxStep * Math.min(1, Math.max(0, ratio))); + } + } + const { index, top } = resolveInsertionIndex(tiptap, host, ev.clientY); + setDragPreview((prev) => ({ + ...prev, + targetInsertionIndex: index, + insertionText: buildDropIndicatorText(index, tiptap.state.doc.childCount), + indicatorTop: Math.max(0, top), + ghostTop: Math.max(0, ev.clientY - hostBounds.top - 18), + })); + }; + + window.onpointerup = () => { + const sourceStartIndex = dragPreviewRef.current.sourceStartIndex === -1 ? sourceStart : dragPreviewRef.current.sourceStartIndex; + const sourceEndIndex = dragPreviewRef.current.sourceEndIndex === -1 ? sourceEnd : dragPreviewRef.current.sourceEndIndex; + const targetInsertionIndex = + dragPreviewRef.current.targetInsertionIndex === -1 ? sourceEnd + 1 : dragPreviewRef.current.targetInsertionIndex; + const nextIndex = moveTopLevelBlockSpanToInsertionIndex(tiptap, sourceStartIndex, sourceEndIndex, targetInsertionIndex); + setActiveBlockIndex(nextIndex); + stopHandleDrag(); + }; + }; + useEffect(() => { if (editor && content !== editor.getHTML()) { queueMicrotask(() => { @@ -185,6 +896,85 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, } }, [content, editor, onEditorReady]); + useEffect(() => { + if (!editor) return; + + const updateBlockMenu = () => { + if (!editable || !contentWrapRef.current) { + applyTopLevelBlockSelectionClasses(editor, []); + setShowBlockMenu(false); + setShowBlockActionMenu(false); + setActiveBlockIndex(null); + return; + } + if (!editor.isFocused) { + applyTopLevelBlockSelectionClasses(editor, []); + setShowBlockMenu(false); + setShowBlockActionMenu(false); + setActiveBlockIndex(null); + return; + } + + const { selection } = editor.state; + const selectedIndices = getSelectedTopLevelBlockIndices(editor); + applyTopLevelBlockSelectionClasses(editor, selectedIndices); + const $from = selection.$from; + if (!$from.parent.isTextblock) { + setShowBlockMenu(false); + setShowBlockActionMenu(false); + setActiveBlockIndex(null); + return; + } + + try { + const coords = editor.view.coordsAtPos(selection.from); + const hostRect = contentWrapRef.current.getBoundingClientRect(); + const nextTop = Math.max(0, coords.top - hostRect.top - 2); + setBlockMenuTop(nextTop); + setShowBlockMenu(true); + setActiveBlockIndex($from.index(0)); + } catch { + setShowBlockMenu(false); + setShowBlockActionMenu(false); + setActiveBlockIndex(null); + } + }; + + updateBlockMenu(); + + editor.on("focus", updateBlockMenu); + editor.on("blur", () => { + applyTopLevelBlockSelectionClasses(editor, []); + setShowBlockMenu(false); + // Keep active block index for action menu clicks after focus loss. + if (!showBlockActionMenuRef.current) { + setActiveBlockIndex(null); + } + }); + editor.on("selectionUpdate", updateBlockMenu); + editor.on("transaction", updateBlockMenu); + window.addEventListener("resize", updateBlockMenu); + + return () => { + applyTopLevelBlockSelectionClasses(editor, []); + editor.off("focus", updateBlockMenu); + editor.off("selectionUpdate", updateBlockMenu); + editor.off("transaction", updateBlockMenu); + window.removeEventListener("resize", updateBlockMenu); + }; + }, [editor, editable]); + + useEffect(() => { + if (!showBlockActionMenu) return; + const onMouseDown = (event: MouseEvent) => { + if (!blockActionMenuRef.current) return; + if (blockActionMenuRef.current.contains(event.target as Node)) return; + setShowBlockActionMenu(false); + }; + window.addEventListener("mousedown", onMouseDown); + return () => window.removeEventListener("mousedown", onMouseDown); + }, [showBlockActionMenu]); + const handleStopAI = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -193,6 +983,17 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, setIsGenerating(false); }; + const showActionToastMessage = (message: string) => { + setActionToast(message); + if (actionToastTimerRef.current) { + window.clearTimeout(actionToastTimerRef.current); + } + actionToastTimerRef.current = window.setTimeout(() => { + setActionToast(null); + actionToastTimerRef.current = null; + }, 1100); + }; + const handleAISuggest = async (prompt: AIPrompt) => { if (!editor) return; @@ -287,9 +1088,310 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
)} -
+
+ {dragPreview.visible && ( +