增加了基础的块功能

This commit is contained in:
2026-02-25 14:57:25 +08:00
parent 5709ef2966
commit f7c2f8f53e
11 changed files with 1565 additions and 16 deletions
+19
View File
@@ -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.
+1
View File
@@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "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" "check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit"
}, },
"dependencies": { "dependencies": {
BIN
View File
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import {
buildDropIndicatorText,
computeSpanTargetStart,
mergeBlockTexts,
moveContiguousSpan,
} from "../src/lib/editor-block-ops.ts";
const cases = [
() => 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.`);
+12
View File
@@ -353,3 +353,15 @@ ul[data-type="taskList"],
/* Let pre handle the background */ /* 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);
}
+91 -2
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react"; import { Lock, Save, Sparkles, Type, Upload, Download, Loader2, History, Trash2 } from "lucide-react";
import { ResizableSidebar } from "@/components/sidebar"; import { ResizableSidebar } from "@/components/sidebar";
import { PromptManagement } from "@/components/settings/prompt-management"; import { PromptManagement } from "@/components/settings/prompt-management";
import { ImportProvider } from "@/components/import-context"; import { ImportProvider } from "@/components/import-context";
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store"; import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
import { useConfirm } from "@/components/confirm-provider"; import { useConfirm } from "@/components/confirm-provider";
import { cleanupAllPageHistories, estimateAllPageHistoriesUsage, trimAllPageHistoriesToRecent } from "@/lib/page-history";
export default function SettingsPage() { export default function SettingsPage() {
const confirm = useConfirm(); const confirm = useConfirm();
@@ -21,6 +22,8 @@ export default function SettingsPage() {
setTableLineHeight, setTableLineHeight,
timezone, timezone,
setTimezone, setTimezone,
localHistory,
setLocalHistory,
aiConfig, aiConfig,
setAIConfig, setAIConfig,
} = useSettingsStore(); } = useSettingsStore();
@@ -30,8 +33,15 @@ export default function SettingsPage() {
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false); const [isRestoring, setIsRestoring] = useState(false);
const [historyMsg, setHistoryMsg] = useState<string | null>(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); const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
setHistoryStats(estimateAllPageHistoriesUsage());
}, []);
const handlePasswordChange = async (e: React.FormEvent) => { const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMsg(null); setMsg(null);
@@ -151,6 +161,85 @@ export default function SettingsPage() {
</label> </label>
</section> </section>
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
<div className="flex items-center gap-2 border-b pb-2">
<History size={18} className="text-primary" />
<h2 className="text-lg font-semibold md:text-xl"></h2>
</div>
<p className="text-sm text-muted-foreground">
</p>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="font-medium">: {localHistory.maxSnapshotsPerPage} </span>
<input
type="range"
min="5"
max="200"
step="1"
value={localHistory.maxSnapshotsPerPage}
onChange={(e) =>
setLocalHistory({ maxSnapshotsPerPage: parseInt(e.target.value, 10) })
}
className="w-full accent-primary"
/>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium">: {localHistory.retentionDays} </span>
<input
type="range"
min="1"
max="365"
step="1"
value={localHistory.retentionDays}
onChange={(e) => setLocalHistory({ retentionDays: parseInt(e.target.value, 10) })}
className="w-full accent-primary"
/>
</label>
</div>
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<div>: {historyStats.keys}</div>
<div>: {historyStats.snapshots}</div>
<div>: {(historyStats.bytes / 1024).toFixed(1)} KB</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
const result = cleanupAllPageHistories();
setHistoryStats(estimateAllPageHistoriesUsage());
setHistoryMsg(`已清理本地历史键 ${result.touched}`);
}}
className="ui-btn-secondary"
>
<Trash2 size={14} />
</button>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={200}
value={trimRecentCount}
onChange={(e) => setTrimRecentCount(Math.max(1, Math.min(200, parseInt(e.target.value || "1", 10))))}
className="ui-input h-9 w-24"
/>
<button
onClick={() => {
const result = trimAllPageHistoriesToRecent(trimRecentCount);
setHistoryStats(estimateAllPageHistoriesUsage());
setHistoryMsg(
`已处理 ${result.touched} 项,删除快照 ${result.removedSnapshots} 条(每篇保留最近 ${trimRecentCount} 条)`
);
}}
className="ui-btn-secondary"
>
N
</button>
</div>
{historyMsg && <span className="text-xs text-muted-foreground">{historyMsg}</span>}
</div>
</section>
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6"> <section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
<div className="flex items-center gap-2 border-b pb-2"> <div className="flex items-center gap-2 border-b pb-2">
<Sparkles size={18} className="text-primary" /> <Sparkles size={18} className="text-primary" />
+1104 -2
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDropIndicatorText,
computeSpanTargetStart,
mergeBlockTexts,
moveContiguousSpan,
} from "./editor-block-ops";
test("mergeBlockTexts joins with blank lines", () => {
assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC");
});
test("computeSpanTargetStart keeps position when dropping into current span", () => {
const next = computeSpanTargetStart(6, 2, 3, 3);
assert.equal(next, 2);
});
test("computeSpanTargetStart moves span upward", () => {
const next = computeSpanTargetStart(6, 3, 4, 1);
assert.equal(next, 1);
});
test("computeSpanTargetStart moves span downward", () => {
const next = computeSpanTargetStart(6, 1, 2, 5);
assert.equal(next, 3);
});
test("moveContiguousSpan reorders blocks upward", () => {
const source = ["A", "B", "C", "D", "E"];
const moved = moveContiguousSpan(source, 2, 3, 1);
assert.equal(moved.changed, true);
assert.equal(moved.targetStart, 1);
assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]);
});
test("moveContiguousSpan reorders blocks to document end", () => {
const source = ["A", "B", "C", "D", "E"];
const moved = moveContiguousSpan(source, 1, 2, 5);
assert.equal(moved.changed, true);
assert.equal(moved.targetStart, 3);
assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]);
});
test("moveContiguousSpan does not change on internal drop", () => {
const source = ["A", "B", "C", "D", "E"];
const moved = moveContiguousSpan(source, 1, 3, 2);
assert.equal(moved.changed, false);
assert.equal(moved.targetStart, 1);
assert.deepEqual(moved.reordered, source);
});
test("buildDropIndicatorText covers start, middle and end", () => {
assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前");
assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前");
assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾");
});
+47
View File
@@ -0,0 +1,47 @@
export function mergeBlockTexts(texts: string[]): string {
return texts.join("\n\n");
}
export function computeSpanTargetStart(
totalCount: number,
sourceStartIndex: number,
sourceEndIndex: number,
insertionIndex: number
): number {
if (totalCount <= 0) return 0;
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= totalCount) return sourceStartIndex;
const movedCount = sourceEndIndex - sourceStartIndex + 1;
const clampedInsertion = Math.max(0, Math.min(totalCount, insertionIndex));
if (clampedInsertion >= sourceStartIndex && clampedInsertion <= sourceEndIndex + 1) return sourceStartIndex;
return clampedInsertion <= sourceStartIndex ? clampedInsertion : clampedInsertion - movedCount;
}
export function moveContiguousSpan<T>(
items: T[],
sourceStartIndex: number,
sourceEndIndex: number,
insertionIndex: number
): { reordered: T[]; targetStart: number; changed: boolean } {
if (items.length === 0) return { reordered: items, targetStart: 0, changed: false };
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= items.length) {
return { reordered: items, targetStart: sourceStartIndex, changed: false };
}
const targetStart = computeSpanTargetStart(items.length, sourceStartIndex, sourceEndIndex, insertionIndex);
if (targetStart === sourceStartIndex) {
return { reordered: items, targetStart, changed: false };
}
const moved = items.slice(sourceStartIndex, sourceEndIndex + 1);
const remaining = [...items.slice(0, sourceStartIndex), ...items.slice(sourceEndIndex + 1)];
const reordered = [...remaining.slice(0, targetStart), ...moved, ...remaining.slice(targetStart)];
return { reordered, targetStart, changed: true };
}
export function buildDropIndicatorText(insertionIndex: number, totalCount: number): string {
const clamped = Math.max(0, Math.min(totalCount, insertionIndex));
if (totalCount <= 0) return "将插入到文档开头";
if (clamped >= totalCount) return "将插入到文档末尾";
return `将插入到第 ${clamped + 1} 块之前`;
}
+173 -12
View File
@@ -1,7 +1,10 @@
import type { Page } from "@/lib/store"; import type { Page } from "@/lib/store";
import { useSettingsStore } from "@/lib/settings-store";
const MAX_SNAPSHOTS_PER_PAGE = 30; const MAX_SNAPSHOTS_PER_PAGE = 30;
const RETENTION_DAYS = 30;
const MIN_SNAPSHOT_INTERVAL_MS = 15_000; const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
const HISTORY_KEY_PREFIX = "noteai:history:";
export type PageSnapshot = { export type PageSnapshot = {
title: string; title: string;
@@ -10,28 +13,67 @@ export type PageSnapshot = {
}; };
function getKey(pageId: string): string { function getKey(pageId: string): string {
return `noteai:history:${pageId}`; return `${HISTORY_KEY_PREFIX}${pageId}`;
} }
function canUseStorage(): boolean { function canUseStorage(): boolean {
return typeof window !== "undefined" && typeof localStorage !== "undefined"; return typeof window !== "undefined" && typeof localStorage !== "undefined";
} }
function isSnapshot(item: unknown): item is PageSnapshot {
return (
!!item &&
typeof item === "object" &&
typeof (item as PageSnapshot).title === "string" &&
typeof (item as PageSnapshot).content === "string" &&
typeof (item as PageSnapshot).timestamp === "string"
);
}
function clampInt(value: number, fallback: number, min: number, max: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
function getRetentionConfig() {
const settings = useSettingsStore.getState().localHistory;
return {
maxSnapshotsPerPage: clampInt(settings?.maxSnapshotsPerPage ?? MAX_SNAPSHOTS_PER_PAGE, MAX_SNAPSHOTS_PER_PAGE, 5, 200),
retentionDays: clampInt(settings?.retentionDays ?? RETENTION_DAYS, RETENTION_DAYS, 1, 365),
};
}
function pruneSnapshots(snapshots: PageSnapshot[]): PageSnapshot[] {
const { maxSnapshotsPerPage, retentionDays } = getRetentionConfig();
const minTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
return snapshots
.filter((item) => {
const ts = new Date(item.timestamp).getTime();
if (Number.isNaN(ts)) return false;
return ts >= minTimestamp;
})
.slice(0, maxSnapshotsPerPage);
}
export function getPageHistory(pageId: string): PageSnapshot[] { export function getPageHistory(pageId: string): PageSnapshot[] {
if (!canUseStorage()) return []; if (!canUseStorage()) return [];
try { try {
const raw = localStorage.getItem(getKey(pageId)); const raw = localStorage.getItem(getKey(pageId));
if (!raw) return []; if (!raw) return [];
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return []; if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is PageSnapshot => const normalized = parsed.filter(isSnapshot);
item && const pruned = pruneSnapshots(normalized);
typeof item === "object" &&
typeof item.title === "string" && if (pruned.length !== normalized.length) {
typeof item.content === "string" && localStorage.setItem(getKey(pageId), JSON.stringify(pruned));
typeof item.timestamp === "string" }
);
return pruned;
} catch { } catch {
return []; return [];
} }
@@ -39,6 +81,7 @@ export function getPageHistory(pageId: string): PageSnapshot[] {
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void { export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
if (!canUseStorage()) return; if (!canUseStorage()) return;
const history = getPageHistory(page.id); const history = getPageHistory(page.id);
const latest = history[0]; const latest = history[0];
@@ -60,12 +103,130 @@ export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
...history, ...history,
].slice(0, MAX_SNAPSHOTS_PER_PAGE); ];
try { try {
localStorage.setItem(getKey(page.id), JSON.stringify(next)); localStorage.setItem(getKey(page.id), JSON.stringify(pruneSnapshots(next)));
} catch { } catch {
// Ignore storage errors. // Ignore storage errors.
} }
} }
export function cleanupAllPageHistories(): { touched: number } {
if (!canUseStorage()) return { touched: 0 };
const targets: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
targets.push(key);
}
let touched = 0;
for (const key of targets) {
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
localStorage.removeItem(key);
touched += 1;
continue;
}
const normalized = parsed.filter(isSnapshot);
const pruned = pruneSnapshots(normalized);
if (pruned.length === 0) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(pruned));
}
touched += 1;
} catch {
localStorage.removeItem(key);
touched += 1;
}
}
return { touched };
}
export function estimateAllPageHistoriesUsage(): {
keys: number;
snapshots: number;
bytes: number;
} {
if (!canUseStorage()) return { keys: 0, snapshots: 0, bytes: 0 };
let keys = 0;
let snapshots = 0;
let bytes = 0;
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
const raw = localStorage.getItem(key);
if (!raw) continue;
keys += 1;
bytes += raw.length * 2;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
snapshots += parsed.filter(isSnapshot).length;
}
} catch {
// ignore parse failures here
}
}
return { keys, snapshots, bytes };
}
export function trimAllPageHistoriesToRecent(limitPerPage: number): {
touched: number;
removedSnapshots: number;
} {
if (!canUseStorage()) return { touched: 0, removedSnapshots: 0 };
const limit = clampInt(limitPerPage, 10, 1, 200);
const targets: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
targets.push(key);
}
let touched = 0;
let removedSnapshots = 0;
for (const key of targets) {
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
localStorage.removeItem(key);
touched += 1;
continue;
}
const normalized = parsed.filter(isSnapshot);
const prunedByPolicy = pruneSnapshots(normalized);
const next = prunedByPolicy.slice(0, limit);
removedSnapshots += Math.max(0, normalized.length - next.length);
if (next.length === 0) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(next));
}
touched += 1;
} catch {
localStorage.removeItem(key);
touched += 1;
}
}
return { touched, removedSnapshots };
}
+17
View File
@@ -38,6 +38,10 @@ interface SettingsState {
lineHeight: number; lineHeight: number;
tableLineHeight: number; tableLineHeight: number;
timezone: string; timezone: string;
localHistory: {
maxSnapshotsPerPage: number;
retentionDays: number;
};
aiConfig: { aiConfig: {
apiKey: string; apiKey: string;
@@ -52,6 +56,7 @@ interface SettingsState {
setLineHeight: (height: number) => void; setLineHeight: (height: number) => void;
setTableLineHeight: (height: number) => void; setTableLineHeight: (height: number) => void;
setTimezone: (timezone: string) => void; setTimezone: (timezone: string) => void;
setLocalHistory: (config: Partial<{ maxSnapshotsPerPage: number; retentionDays: number }>) => void;
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void; setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
setPrompts: (prompts: AIPrompt[]) => void; setPrompts: (prompts: AIPrompt[]) => void;
@@ -69,6 +74,10 @@ export const useSettingsStore = create<SettingsState>()(
lineHeight: 1.5, lineHeight: 1.5,
tableLineHeight: 1.2, tableLineHeight: 1.2,
timezone: "Asia/Shanghai", timezone: "Asia/Shanghai",
localHistory: {
maxSnapshotsPerPage: 30,
retentionDays: 30,
},
aiConfig: { aiConfig: {
apiKey: "", apiKey: "",
@@ -83,6 +92,13 @@ export const useSettingsStore = create<SettingsState>()(
setLineHeight: (height) => set({ lineHeight: height }), setLineHeight: (height) => set({ lineHeight: height }),
setTableLineHeight: (height) => set({ tableLineHeight: height }), setTableLineHeight: (height) => set({ tableLineHeight: height }),
setTimezone: (timezone) => set({ timezone }), setTimezone: (timezone) => set({ timezone }),
setLocalHistory: (config) =>
set((state) => ({
localHistory: {
...state.localHistory,
...config,
},
})),
setAIConfig: (config) => setAIConfig: (config) =>
set((state) => ({ set((state) => ({
aiConfig: { aiConfig: {
@@ -108,6 +124,7 @@ export const useSettingsStore = create<SettingsState>()(
lineHeight: state.lineHeight, lineHeight: state.lineHeight,
tableLineHeight: state.tableLineHeight, tableLineHeight: state.tableLineHeight,
timezone: state.timezone, timezone: state.timezone,
localHistory: state.localHistory,
aiConfig: { aiConfig: {
baseURL: state.aiConfig.baseURL, baseURL: state.aiConfig.baseURL,
model: state.aiConfig.model, model: state.aiConfig.model,