prts-terrarchive 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { evidenceCardAppearance } from './evidence-card-style';
|
|
2
|
+
import { createEvidenceId, createEvidenceTemplate, createPreviewCards, EVIDENCE_BODY_LIMIT, EVIDENCE_CARD_CAPACITY,
|
|
3
|
+
EVIDENCE_STAGES, EVIDENCE_STORAGE_LIMIT, EVIDENCE_TITLE_LIMIT, EVIDENCE_SCALE_MIN, EVIDENCE_SCALE_MAX, clampEvidencePosition,
|
|
4
|
+
evidenceCardLayout, evidenceCardScale, evidenceStorageKey, isPreviewCard, parseEvidenceCards,
|
|
5
|
+
type EvidenceBoardTool, type EvidenceCard, type EvidenceTemplate } from './evidence-board-model';
|
|
6
|
+
import { rebaseEvidenceHistory, createEvidenceHistory, type EvidenceSnapshot } from './evidence-board-history';
|
|
7
|
+
|
|
8
|
+
type Point = { x: number; y: number };
|
|
9
|
+
type Options = {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
managed?: boolean;
|
|
12
|
+
onChange: (cards: EvidenceCard[]) => void;
|
|
13
|
+
onSelect: (id: string | null) => void;
|
|
14
|
+
onBrowse: () => void;
|
|
15
|
+
onToolChange: (tool: EvidenceBoardTool) => void;
|
|
16
|
+
onToolsOpen?: (open: boolean) => void;
|
|
17
|
+
onViewportInset?: (bottomFraction: number) => void;
|
|
18
|
+
getCardBounds?: (id: string) => { left: number; right: number; top: number; bottom: number } | null;
|
|
19
|
+
};
|
|
20
|
+
const PREVIEW_LAYOUT_VERSION = 3;
|
|
21
|
+
const TEMPLATE_NAMES: Record<EvidenceTemplate, string> = { note: '短便签', question: '疑问签', source: '资料卡', tag: '关键词标签' };
|
|
22
|
+
|
|
23
|
+
function element<K extends keyof HTMLElementTagNameMap>(tag: K, name: string, text?: string) {
|
|
24
|
+
const result = document.createElement(tag);
|
|
25
|
+
result.className = `rhine-evidence-${name}`;
|
|
26
|
+
if (text !== undefined) result.textContent = text;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A single movable tool window; cards and placement previews belong to the shared scene. */
|
|
31
|
+
export function mountEvidenceBoardPanel(host: HTMLElement, options: Options) {
|
|
32
|
+
let sessionId = options.sessionId, cards: EvidenceCard[] = [], selected: string | null = null;
|
|
33
|
+
let active = false, disposed = false, directoryOpen = false, toolsOpen = false, editorCollapsed = false;
|
|
34
|
+
let tool: EvidenceBoardTool = { mode: 'select' };
|
|
35
|
+
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
let scaleTimer: ReturnType<typeof setTimeout> | undefined, scaleDirty = false, scalePointerActive = false;
|
|
37
|
+
let scaleBefore: EvidenceSnapshot | null = null;
|
|
38
|
+
let editorRevision: number | undefined;
|
|
39
|
+
let storageAvailable = true, toolPosition: Point | null = null;
|
|
40
|
+
let viewportInset = 0, positionedCard: string | null = null;
|
|
41
|
+
let windowDrag: { id: number; x: number; y: number; start: Point; scale: number } | null = null;
|
|
42
|
+
const history = createEvidenceHistory();
|
|
43
|
+
const promotedIds = new Map<string, string>();
|
|
44
|
+
const events = new AbortController();
|
|
45
|
+
const root = host.closest('.rhine-workbench');
|
|
46
|
+
const panel = element('section', 'panel');
|
|
47
|
+
panel.hidden = true;
|
|
48
|
+
panel.setAttribute('aria-label', '证据板');
|
|
49
|
+
panel.append(element('p', 'kicker', 'EVIDENCE BOARD'), element('h2', 'heading', '证据板'));
|
|
50
|
+
const countLabel = element('p', 'count');
|
|
51
|
+
const makeButton = (text: string, name: string, action: () => void) => {
|
|
52
|
+
const button = element('button', name, text);
|
|
53
|
+
button.type = 'button';
|
|
54
|
+
button.addEventListener('click', action, { signal: events.signal });
|
|
55
|
+
return button;
|
|
56
|
+
};
|
|
57
|
+
const newButton = makeButton('整理线索 ↗', 'new', () => {
|
|
58
|
+
if (sceneUnavailable()) placeCard('note');
|
|
59
|
+
else openTools();
|
|
60
|
+
});
|
|
61
|
+
const directoryButton = makeButton('线索目录', 'directory-toggle', () => {
|
|
62
|
+
directoryOpen = !directoryOpen; renderDirectoryVisibility();
|
|
63
|
+
});
|
|
64
|
+
const directory = element('div', 'directory');
|
|
65
|
+
directory.id = `rhine-evidence-directory-${createEvidenceId().replace(/[^a-zA-Z0-9-]/g, '')}`;
|
|
66
|
+
directoryButton.setAttribute('aria-controls', directory.id);
|
|
67
|
+
const list = element('ul', 'card-list'); directory.append(list);
|
|
68
|
+
const actions = element('div', 'actions'); actions.append(newButton, directoryButton);
|
|
69
|
+
panel.append(countLabel, actions, directory);
|
|
70
|
+
|
|
71
|
+
const floating = element('section', 'tools');
|
|
72
|
+
floating.hidden = true; floating.setAttribute('role', 'dialog');
|
|
73
|
+
floating.setAttribute('aria-label', '整理线索');
|
|
74
|
+
const heading = element('div', 'tools-heading');
|
|
75
|
+
const headingText = element('div', 'tools-heading-text');
|
|
76
|
+
headingText.append(element('span', 'tools-kicker', 'FIELD TOOLS'), element('strong', 'tools-title', '整理线索'));
|
|
77
|
+
const closeButton = makeButton('×', 'close-tools', closeTools);
|
|
78
|
+
closeButton.setAttribute('aria-label', '关闭整理工具');
|
|
79
|
+
const collapseButton = makeButton('收起', 'collapse-tools', () => {
|
|
80
|
+
if (editorCollapsed) expandTools(); else minimizeTools();
|
|
81
|
+
});
|
|
82
|
+
collapseButton.setAttribute('aria-label', '收起工具,保留线索选中');
|
|
83
|
+
collapseButton.setAttribute('aria-expanded', 'true');
|
|
84
|
+
const headingActions = element('div', 'heading-actions'); headingActions.append(collapseButton, closeButton);
|
|
85
|
+
heading.append(headingText, headingActions);
|
|
86
|
+
const backButton = makeButton('‹ 全部工具', 'back-tools', () => { select(null); openTools(); });
|
|
87
|
+
const modeRow = element('div', 'mode-row');
|
|
88
|
+
const modeText = element('p', 'mode-text');
|
|
89
|
+
const cancelToolButton = makeButton('取消', 'cancel-tool', () => setTool({ mode: 'select' }));
|
|
90
|
+
modeRow.append(modeText, cancelToolButton); modeRow.hidden = true;
|
|
91
|
+
const home = element('div', 'tools-home');
|
|
92
|
+
home.append(element('p', 'tools-intro', '把发现钉在这里,让线索彼此相连。'));
|
|
93
|
+
const templates = element('div', 'templates');
|
|
94
|
+
const templateButtons: HTMLButtonElement[] = [];
|
|
95
|
+
for (const [template, description, marker] of [
|
|
96
|
+
['note', '记录一条发现', '▱'], ['question', '留住未解之处', '?'], ['source', '原文、来源与摘录', '≡'],
|
|
97
|
+
] as const) {
|
|
98
|
+
const button = makeButton('', 'template', () => beginPlacement(template));
|
|
99
|
+
button.dataset.template = template;
|
|
100
|
+
button.append(element('span', `template-mark is-${template}`, marker), element('strong', 'template-name', TEMPLATE_NAMES[template]),
|
|
101
|
+
element('span', 'template-description', description));
|
|
102
|
+
templates.append(button); templateButtons.push(button);
|
|
103
|
+
}
|
|
104
|
+
const connectButton = makeButton('', 'template', () => {
|
|
105
|
+
flushEditor(); selected = null; renderEditor(); options.onSelect(null);
|
|
106
|
+
setTool({ mode: 'connect' });
|
|
107
|
+
});
|
|
108
|
+
connectButton.dataset.tool = 'connect';
|
|
109
|
+
connectButton.append(element('span', 'template-mark is-connect', '●—●'), element('strong', 'template-name', '红绳连线'),
|
|
110
|
+
element('span', 'template-description', '依次点选两条线索'));
|
|
111
|
+
templates.append(connectButton);
|
|
112
|
+
const tagButton = makeButton('+ 关键词标签', 'tag-template', () => beginPlacement('tag'));
|
|
113
|
+
tagButton.dataset.template = 'tag'; templateButtons.push(tagButton);
|
|
114
|
+
const browseButton = makeButton('检索资料 ↗', 'browse', () => { flushEditor(); setTool({ mode: 'select' }); options.onBrowse(); });
|
|
115
|
+
const homeMore = element('details', 'tools-more');
|
|
116
|
+
const sampleButton = makeButton('清空未编辑的示例', 'clear-examples', () => {
|
|
117
|
+
flushEditor(); const before = snapshot(); setTool({ mode: 'select' });
|
|
118
|
+
cards = cards.filter(card => !isPreviewCard(card));
|
|
119
|
+
if (!cards.some(card => card.id === selected)) selected = null;
|
|
120
|
+
publish(before, '清空示例'); renderEditor(); options.onSelect(selected);
|
|
121
|
+
announce('未编辑的示例已清空。');
|
|
122
|
+
});
|
|
123
|
+
homeMore.append(element('summary', 'attributes-summary', '更多操作'), sampleButton);
|
|
124
|
+
const shortcuts = element('details', 'shortcuts');
|
|
125
|
+
shortcuts.append(element('summary', 'attributes-summary', '键盘快捷操作'),
|
|
126
|
+
element('p', 'tools-help', 'N 新便签 · L 红绳连线 · V 选择\n⌘ / Ctrl D 复制 · Delete 移除\n⌘ / Ctrl Z 撤销 · 加 Shift 重做\nEsc 取消当前操作'));
|
|
127
|
+
home.append(templates, tagButton, browseButton, element('p', 'tools-help', '空白拖动平移 · 滚轮缩放\n空格或中键可从纸片上拖动画面'), shortcuts, homeMore);
|
|
128
|
+
|
|
129
|
+
const editor = element('form', 'editor'); editor.hidden = true;
|
|
130
|
+
const editorBody = element('div', 'editor-body');
|
|
131
|
+
const title = element('textarea', 'title-input');
|
|
132
|
+
title.rows = 1; title.required = true; title.maxLength = EVIDENCE_TITLE_LIMIT;
|
|
133
|
+
title.setAttribute('aria-label', '线索标题'); title.placeholder = '给这条线索起个标题';
|
|
134
|
+
const body = element('textarea', 'body-input'); body.rows = 4; body.maxLength = EVIDENCE_BODY_LIMIT;
|
|
135
|
+
body.setAttribute('aria-label', '线索内容'); body.placeholder = '记录发现、依据,以及仍需核对的地方…';
|
|
136
|
+
const stage = element('select', 'stage-input');
|
|
137
|
+
EVIDENCE_STAGES.forEach((name, index) => {
|
|
138
|
+
const option = document.createElement('option'); option.value = String(index); option.textContent = `0${index + 1} ${name}`; stage.append(option);
|
|
139
|
+
});
|
|
140
|
+
const kind = element('select', 'kind-input');
|
|
141
|
+
for (const [value, label] of [['note', '观察笔记'], ['source', '资料摘录'], ['question', '待查问题']]) {
|
|
142
|
+
const option = document.createElement('option'); option.value = value; option.textContent = label; kind.append(option);
|
|
143
|
+
}
|
|
144
|
+
const field = (label: string, control: HTMLElement) => {
|
|
145
|
+
const wrapper = element('label', 'field'); wrapper.append(element('span', 'field-label', label), control); return wrapper;
|
|
146
|
+
};
|
|
147
|
+
const selects = element('div', 'selects'); selects.append(field('研究阶段', stage), field('线索类型', kind));
|
|
148
|
+
const attributes = element('details', 'attributes');
|
|
149
|
+
const deleteButton = makeButton('移除这条线索', 'remove', removeSelected);
|
|
150
|
+
attributes.append(element('summary', 'attributes-summary', '更多属性与操作'), selects, deleteButton);
|
|
151
|
+
const sourceLabel = element('p', 'source-label');
|
|
152
|
+
const sizeControl = element('div', 'size-control');
|
|
153
|
+
const sizeHeader = element('div', 'size-heading');
|
|
154
|
+
const sizeRange = element('input', 'size-range'); sizeRange.type = 'range';
|
|
155
|
+
sizeRange.min = String(EVIDENCE_SCALE_MIN); sizeRange.max = String(EVIDENCE_SCALE_MAX); sizeRange.step = 'any';
|
|
156
|
+
sizeRange.setAttribute('aria-label', '线索纸片大小');
|
|
157
|
+
const sizeValue = element('output', 'size-value', '100%');
|
|
158
|
+
const sizeReset = makeButton('原大小', 'size-reset', () => { resizeCurrent(1); commitScale(); });
|
|
159
|
+
sizeReset.setAttribute('aria-label', '恢复这条线索的原大小');
|
|
160
|
+
sizeHeader.append(element('span', 'field-label', '纸片大小'), sizeValue, sizeReset);
|
|
161
|
+
const sizeTrack = element('div', 'size-track');
|
|
162
|
+
const sizeDown = makeButton('−', 'size-down', () => { nudgeSize(-0.1); });
|
|
163
|
+
const sizeUp = makeButton('+', 'size-up', () => { nudgeSize(0.1); });
|
|
164
|
+
sizeDown.setAttribute('aria-label', '缩小这条线索'); sizeUp.setAttribute('aria-label', '放大这条线索');
|
|
165
|
+
sizeTrack.append(sizeDown, sizeRange, sizeUp); sizeControl.append(sizeHeader, sizeTrack);
|
|
166
|
+
sizeControl.append(element('p', 'size-help', '也可拖动选中纸片右下角,直接调整大小。'));
|
|
167
|
+
const selectedActions = element('div', 'selected-actions');
|
|
168
|
+
const connectSelectedButton = makeButton('●—● 从此处连线', 'connect-selected', startConnection);
|
|
169
|
+
connectSelectedButton.title = '从选中的线索拉一条红绳(L)';
|
|
170
|
+
const duplicateButton = makeButton('+ 复制', 'duplicate', duplicateSelected);
|
|
171
|
+
duplicateButton.title = '复制选中的纸片(Ctrl / ⌘ D)';
|
|
172
|
+
selectedActions.append(connectSelectedButton, duplicateButton);
|
|
173
|
+
const editorActions = element('div', 'editor-actions');
|
|
174
|
+
const saveButton = element('button', 'save', '保存线索'); saveButton.type = 'submit';
|
|
175
|
+
editorActions.append(saveButton, element('span', 'autosave', '输入自动保存'));
|
|
176
|
+
const connections = element('details', 'connections');
|
|
177
|
+
const linkTarget = element('select', 'link-target');
|
|
178
|
+
const linkAdd = makeButton('连线 +', 'link-add', () => { if (selected) connectCards(selected, linkTarget.value); });
|
|
179
|
+
const linkControls = element('div', 'link-controls'); linkControls.append(linkTarget, linkAdd);
|
|
180
|
+
const linkList = element('ul', 'link-list');
|
|
181
|
+
const connectionsSummary = element('summary', 'attributes-summary', '关联线索');
|
|
182
|
+
connections.append(connectionsSummary, field('连到另一条线索', linkControls), linkList);
|
|
183
|
+
linkTarget.setAttribute('aria-label', '连到另一条线索');
|
|
184
|
+
linkTarget.addEventListener('change', () => { linkAdd.disabled = !linkTarget.value; }, { signal: events.signal });
|
|
185
|
+
const paper = element('article', 'edit-paper');
|
|
186
|
+
const paperMeta = element('div', 'paper-meta');
|
|
187
|
+
const paperKind = element('span', 'paper-kind'), paperId = element('span', 'paper-id');
|
|
188
|
+
paperMeta.append(paperKind, paperId);
|
|
189
|
+
const titleField = field('标题', title), bodyField = field('内容', body);
|
|
190
|
+
titleField.classList.add('is-paper-title'); bodyField.classList.add('is-paper-body');
|
|
191
|
+
const paperFoot = element('div', 'paper-foot');
|
|
192
|
+
const paperSource = element('span', 'paper-source'), paperEvidence = element('span', 'paper-evidence');
|
|
193
|
+
paperFoot.append(paperSource, paperEvidence);
|
|
194
|
+
paper.append(paperMeta, titleField, bodyField, sourceLabel, paperFoot);
|
|
195
|
+
const adjustments = element('details', 'adjustments');
|
|
196
|
+
adjustments.append(element('summary', 'attributes-summary', '纸片大小与连线'), sizeControl, connections, attributes);
|
|
197
|
+
editorBody.append(paper, selectedActions, adjustments);
|
|
198
|
+
editor.append(editorBody, editorActions);
|
|
199
|
+
const status = element('p', 'status'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
|
|
200
|
+
const footer = element('div', 'tools-footer');
|
|
201
|
+
const undoButton = makeButton('↶ 撤销', 'undo', undoLast);
|
|
202
|
+
const redoButton = makeButton('↷ 重做', 'redo', redoLast);
|
|
203
|
+
const historyActions = element('div', 'history-actions'); historyActions.append(undoButton, redoButton);
|
|
204
|
+
footer.append(status, historyActions);
|
|
205
|
+
floating.append(heading, backButton, modeRow, home, editor, footer);
|
|
206
|
+
host.append(panel, floating);
|
|
207
|
+
|
|
208
|
+
function sceneUnavailable() { return Boolean(root?.classList.contains('rhine-scene-unavailable')); }
|
|
209
|
+
function announce(message: string) { status.textContent = message; }
|
|
210
|
+
function cloneCards(values: EvidenceCard[]) { return values.map(card => ({ ...card,
|
|
211
|
+
...(card.position ? { position: { ...card.position } } : {}), ...(card.links ? { links: [...card.links] } : {}),
|
|
212
|
+
})); }
|
|
213
|
+
function copyCards() { return cloneCards(cards); }
|
|
214
|
+
function cancelSave() { clearTimeout(saveTimer); saveTimer = undefined; }
|
|
215
|
+
function resolveId(id: string) { return promotedIds.get(id) || id; }
|
|
216
|
+
function promoteCard(id: string) {
|
|
217
|
+
const card = cards.find(value => value.id === id);
|
|
218
|
+
if (!card || !isPreviewCard(card)) return id;
|
|
219
|
+
const nextId = createEvidenceId(); card.id = nextId;
|
|
220
|
+
for (const other of cards) if (other.links) other.links = other.links.map(value => value === id ? nextId : value);
|
|
221
|
+
promotedIds.set(id, nextId); if (selected === id) selected = nextId;
|
|
222
|
+
return nextId;
|
|
223
|
+
}
|
|
224
|
+
function cleanLinks() {
|
|
225
|
+
const ids = new Set(cards.map(card => card.id)), edges = new Set<string>();
|
|
226
|
+
for (const card of cards) if (card.links) card.links = card.links.filter(id => {
|
|
227
|
+
if (id === card.id || !ids.has(id)) return false;
|
|
228
|
+
const edge = JSON.stringify([card.id, id].sort());
|
|
229
|
+
if (edges.has(edge)) return false;
|
|
230
|
+
edges.add(edge); return true;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function connectedIds(card: EvidenceCard) {
|
|
234
|
+
return new Set([...(card.links || []), ...cards.filter(other => other.links?.includes(card.id)).map(other => other.id)]);
|
|
235
|
+
}
|
|
236
|
+
function freezeLayout() {
|
|
237
|
+
cards = cards.map((card, index) => {
|
|
238
|
+
const layout = evidenceCardLayout(card, index); return { ...card, position: { x: layout.x, y: layout.y }, rotation: layout.rotation };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function positionNewCard(card: EvidenceCard) {
|
|
242
|
+
let best = evidenceCardLayout(card, 0), distance = -Infinity;
|
|
243
|
+
for (let index = 0; index < EVIDENCE_CARD_CAPACITY; index++) {
|
|
244
|
+
const candidate = evidenceCardLayout(card, index);
|
|
245
|
+
const nearest = cards.length ? Math.min(...cards.map((other, otherIndex) => {
|
|
246
|
+
const placed = evidenceCardLayout(other, otherIndex); return (placed.x - candidate.x) ** 2 + (placed.y - candidate.y) ** 2;
|
|
247
|
+
})) : Infinity;
|
|
248
|
+
if (nearest > distance) { best = candidate; distance = nearest; }
|
|
249
|
+
}
|
|
250
|
+
return { ...card, position: { x: best.x, y: best.y }, rotation: best.rotation };
|
|
251
|
+
}
|
|
252
|
+
function snapshot(): EvidenceSnapshot { return { cards: copyCards(), selected, promotions: [...promotedIds] }; }
|
|
253
|
+
function restoreHistory(direction: 'undo' | 'redo') {
|
|
254
|
+
if (disposed) return false;
|
|
255
|
+
// The newest visible draft is an operation too; undo must recover it on redo.
|
|
256
|
+
flushEditor(); setTool({ mode: 'select' });
|
|
257
|
+
const entry = history[direction](); if (!entry) return false;
|
|
258
|
+
cards = options.managed ? rebaseEvidenceHistory(cards, entry.from.cards, entry.state.cards) : cloneCards(entry.state.cards); selected = entry.state.selected;
|
|
259
|
+
promotedIds.clear(); entry.state.promotions.forEach(([from, to]) => promotedIds.set(from, to));
|
|
260
|
+
publish(); renderEditor(); options.onSelect(selected);
|
|
261
|
+
announce(`已${direction === 'undo' ? '撤销' : '重做'}${entry.label}。`); return true;
|
|
262
|
+
}
|
|
263
|
+
function undoLast() { return restoreHistory('undo'); }
|
|
264
|
+
function redoLast() { return restoreHistory('redo'); }
|
|
265
|
+
function persist() {
|
|
266
|
+
if (options.managed) return;
|
|
267
|
+
try {
|
|
268
|
+
localStorage.setItem(evidenceStorageKey(sessionId), JSON.stringify({ version: 1, previewLayout: PREVIEW_LAYOUT_VERSION, cards }));
|
|
269
|
+
storageAvailable = true;
|
|
270
|
+
} catch {
|
|
271
|
+
storageAvailable = false; announce('浏览器存储不可用;本次打开期间仍可编辑,关闭前请保留重要内容。');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function publish(before?: EvidenceSnapshot, label = '编辑线索', group?: string) {
|
|
275
|
+
cleanLinks();
|
|
276
|
+
if (before) history.record(before, snapshot(), label, group);
|
|
277
|
+
persist(); renderOverview(); renderLinks(); renderUndo(); options.onChange(copyCards());
|
|
278
|
+
}
|
|
279
|
+
function load() {
|
|
280
|
+
cards = [];
|
|
281
|
+
if (options.managed) { renderOverview(); renderLinks(); renderUndo(); options.onChange([]); return; }
|
|
282
|
+
try {
|
|
283
|
+
const raw = localStorage.getItem(evidenceStorageKey(sessionId));
|
|
284
|
+
if (raw === null) { cards = createPreviewCards(); announce('孤星 · 六则剧情研究示例,可编辑或清空。'); }
|
|
285
|
+
else if (raw.length <= EVIDENCE_STORAGE_LIMIT) {
|
|
286
|
+
const saved: unknown = JSON.parse(raw);
|
|
287
|
+
cards = saved && typeof saved === 'object' && !Array.isArray(saved) && (saved as { version?: unknown }).version === 1
|
|
288
|
+
? parseEvidenceCards((saved as { cards?: unknown }).cards) : [];
|
|
289
|
+
if (saved && typeof saved === 'object' && (saved as { previewLayout?: unknown }).previewLayout !== PREVIEW_LAYOUT_VERSION) {
|
|
290
|
+
const previews = new Map(createPreviewCards().map(card => [card.id, card]));
|
|
291
|
+
const preserveLayout = (saved as { previewLayout?: unknown }).previewLayout === 2;
|
|
292
|
+
cards = cards.map(card => {
|
|
293
|
+
const preview = isPreviewCard(card) ? previews.get(card.id) : undefined;
|
|
294
|
+
if (!preview) return card;
|
|
295
|
+
return preserveLayout ? { ...preview, ...(card.position ? { position: { ...card.position } } : {}),
|
|
296
|
+
...(card.rotation !== undefined ? { rotation: card.rotation } : {}) } : preview;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
announce('当前会话 · 手动线索已载入');
|
|
300
|
+
} else announce('已跳过超出容量的证据板记录,可以重新添加线索。');
|
|
301
|
+
storageAvailable = true;
|
|
302
|
+
} catch { storageAvailable = false; announce('未能载入浏览器中的证据板;本次打开期间仍可编辑。'); }
|
|
303
|
+
freezeLayout(); cleanLinks(); renderOverview(); renderEditor(); renderUndo(); options.onChange(copyCards()); options.onSelect(null);
|
|
304
|
+
}
|
|
305
|
+
function renderDirectoryVisibility() {
|
|
306
|
+
const fallback = sceneUnavailable();
|
|
307
|
+
directory.classList.toggle('is-open', directoryOpen || fallback);
|
|
308
|
+
directoryButton.hidden = fallback;
|
|
309
|
+
directoryButton.setAttribute('aria-expanded', String(directoryOpen || fallback));
|
|
310
|
+
directoryButton.textContent = `${directoryOpen ? '收起' : ''}线索目录 · ${String(cards.length).padStart(2, '0')}`;
|
|
311
|
+
newButton.textContent = fallback ? '+ 新建线索' : '整理线索 ↗';
|
|
312
|
+
newButton.disabled = fallback && cards.length >= EVIDENCE_CARD_CAPACITY;
|
|
313
|
+
connectButton.disabled = !fallback && cards.length < 2;
|
|
314
|
+
connectButton.hidden = fallback;
|
|
315
|
+
}
|
|
316
|
+
function renderOverview() {
|
|
317
|
+
countLabel.textContent = `已钉 ${String(cards.length).padStart(2, '0')} / ${EVIDENCE_CARD_CAPACITY} 条线索`;
|
|
318
|
+
for (const button of templateButtons) {
|
|
319
|
+
button.disabled = cards.length >= EVIDENCE_CARD_CAPACITY;
|
|
320
|
+
button.title = button.disabled ? `${EVIDENCE_CARD_CAPACITY} 张纸片已满,移除一条后可继续添加` : '选择后在板面点击放置';
|
|
321
|
+
}
|
|
322
|
+
sampleButton.hidden = !cards.some(isPreviewCard); homeMore.hidden = sampleButton.hidden; list.replaceChildren();
|
|
323
|
+
duplicateButton.disabled = !selected || cards.length >= EVIDENCE_CARD_CAPACITY;
|
|
324
|
+
if (!cards.length) list.append(element('li', 'empty', '证据板还是空的,试着记录第一条线索。'));
|
|
325
|
+
for (const card of cards) {
|
|
326
|
+
const row = element('li', 'card-row'), button = element('button', 'card-button');
|
|
327
|
+
button.type = 'button'; button.setAttribute('aria-pressed', String(card.id === selected));
|
|
328
|
+
button.append(element('span', 'card-marker', card.kind === 'question' ? '?' : card.kind === 'source' ? '↗' : '·'), element('span', 'card-title', card.title));
|
|
329
|
+
button.addEventListener('click', () => { setTool({ mode: 'select' }); select(card.id); });
|
|
330
|
+
row.append(button); list.append(row);
|
|
331
|
+
}
|
|
332
|
+
renderDirectoryVisibility();
|
|
333
|
+
}
|
|
334
|
+
function renderEditor() {
|
|
335
|
+
const card = cards.find(value => value.id === selected);
|
|
336
|
+
editor.hidden = !card; home.hidden = Boolean(card); backButton.hidden = !card;
|
|
337
|
+
floating.classList.toggle('has-selection', Boolean(card));
|
|
338
|
+
renderCollapsed();
|
|
339
|
+
if (card) {
|
|
340
|
+
renderPaper(card);
|
|
341
|
+
editorRevision = card.contentRevision;
|
|
342
|
+
title.value = card.title; body.value = card.body; stage.value = String(card.stage); kind.value = card.kind;
|
|
343
|
+
sourceLabel.hidden = !card.sourceTitle; sourceLabel.textContent = card.sourceTitle ? `资料来源:${card.sourceTitle}` : '';
|
|
344
|
+
renderSize(card);
|
|
345
|
+
}
|
|
346
|
+
renderLinks(); if (toolsOpen) positionTools();
|
|
347
|
+
}
|
|
348
|
+
function fitPaperText() {
|
|
349
|
+
if (floating.hidden || editor.hidden || editorCollapsed) return;
|
|
350
|
+
for (const input of [title, body]) {
|
|
351
|
+
input.style.height = 'auto';
|
|
352
|
+
input.style.height = `${Math.min(input === title ? 230 : 320, Math.max(input === title ? 40 : 150, input.scrollHeight))}px`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function renderPaper(card: EvidenceCard) {
|
|
356
|
+
const appearance = evidenceCardAppearance(card);
|
|
357
|
+
paper.dataset.kind = appearance.kind;
|
|
358
|
+
paper.style.setProperty('--paper-stock', appearance.paper);
|
|
359
|
+
paper.style.setProperty('--paper-accent', appearance.accent);
|
|
360
|
+
paperKind.textContent = appearance.name;
|
|
361
|
+
paperId.textContent = card.clueKind ? card.id : `NOTE / ${String(cards.indexOf(card) + 1).padStart(2, '0')}`;
|
|
362
|
+
paperSource.textContent = card.sourceLabel || (card.sourceTitle ? '保留原文来源' : '手动线索');
|
|
363
|
+
paperEvidence.textContent = card.evidenceLabel || '待核验';
|
|
364
|
+
fitPaperText();
|
|
365
|
+
}
|
|
366
|
+
function renderSize(card: EvidenceCard) {
|
|
367
|
+
const scale = evidenceCardScale(card), maximum = evidenceCardScale({ ...card, scale: EVIDENCE_SCALE_MAX });
|
|
368
|
+
const label = `${Math.round(scale * 100)}%`;
|
|
369
|
+
sizeRange.max = String(maximum); sizeRange.value = String(scale);
|
|
370
|
+
sizeRange.setAttribute('aria-valuetext', label); sizeValue.value = label;
|
|
371
|
+
sizeDown.disabled = scale <= EVIDENCE_SCALE_MIN + 1e-6; sizeUp.disabled = scale >= maximum - 1e-6;
|
|
372
|
+
sizeReset.disabled = Math.abs(scale - 1) < 1e-6;
|
|
373
|
+
}
|
|
374
|
+
function commitScale() {
|
|
375
|
+
clearTimeout(scaleTimer); scaleTimer = undefined;
|
|
376
|
+
if (!scaleDirty || disposed) return;
|
|
377
|
+
const before = scaleBefore; scaleDirty = false; scaleBefore = null;
|
|
378
|
+
publish(before || undefined, '调整纸片大小');
|
|
379
|
+
if (storageAvailable) announce('纸片大小已保存。');
|
|
380
|
+
}
|
|
381
|
+
function resizeCurrent(value: number) {
|
|
382
|
+
if (disposed || !Number.isFinite(value)) return;
|
|
383
|
+
// Flush the text draft once before a resize gesture. Intermediate slider
|
|
384
|
+
// values update the scene; persistence and directory work wait for release.
|
|
385
|
+
if (!scaleDirty) flushEditor();
|
|
386
|
+
const index = cards.findIndex(card => card.id === selected); if (index < 0) return;
|
|
387
|
+
const previous = cards[index], scale = evidenceCardScale({ ...previous, scale: value });
|
|
388
|
+
if (Math.abs(evidenceCardScale(previous) - scale) < 1e-6) { renderSize(previous); return; }
|
|
389
|
+
if (!scaleDirty) scaleBefore = snapshot();
|
|
390
|
+
promoteCard(previous.id);
|
|
391
|
+
const updated = { ...cards[index], scale }, layout = evidenceCardLayout(updated, index);
|
|
392
|
+
updated.position = { x: layout.x, y: layout.y }; cards[index] = updated; selected = updated.id;
|
|
393
|
+
scaleDirty = true; renderUndo(); renderSize(updated);
|
|
394
|
+
options.onChange(copyCards()); options.onSelect(selected);
|
|
395
|
+
clearTimeout(scaleTimer);
|
|
396
|
+
if (!scalePointerActive) scaleTimer = setTimeout(commitScale, 250);
|
|
397
|
+
}
|
|
398
|
+
function nudgeSize(delta: number) {
|
|
399
|
+
const card = cards.find(value => value.id === selected); if (!card) return;
|
|
400
|
+
resizeCurrent(Math.round((evidenceCardScale(card) + delta) * 100) / 100); commitScale();
|
|
401
|
+
}
|
|
402
|
+
function renderUndo() {
|
|
403
|
+
const state = history.stats();
|
|
404
|
+
undoButton.disabled = !state.undoCount && !scaleDirty; redoButton.disabled = !state.redoCount || scaleDirty;
|
|
405
|
+
undoButton.title = state.undoLabel ? `撤销${state.undoLabel}(Ctrl / ⌘ Z)` : '暂无可撤销的操作';
|
|
406
|
+
redoButton.title = state.redoLabel ? `重做${state.redoLabel}(Ctrl / ⌘ Shift Z)` : '暂无可重做的操作';
|
|
407
|
+
}
|
|
408
|
+
function renderLinks() {
|
|
409
|
+
const card = cards.find(value => value.id === selected), previousTarget = linkTarget.value;
|
|
410
|
+
linkTarget.replaceChildren(); linkList.replaceChildren();
|
|
411
|
+
const linked = card ? connectedIds(card) : new Set<string>();
|
|
412
|
+
connectionsSummary.textContent = `关联线索${linked.size ? ` · ${linked.size}` : ''}`;
|
|
413
|
+
const candidates = cards.filter(value => card && value.id !== card.id && !linked.has(value.id));
|
|
414
|
+
connectSelectedButton.disabled = !candidates.length;
|
|
415
|
+
connectSelectedButton.hidden = sceneUnavailable();
|
|
416
|
+
const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = candidates.length ? '选择一条线索' : '暂无可连接的线索';
|
|
417
|
+
linkTarget.append(placeholder);
|
|
418
|
+
for (const target of candidates) { const option = document.createElement('option'); option.value = target.id; option.textContent = target.title; linkTarget.append(option); }
|
|
419
|
+
if (candidates.some(value => value.id === previousTarget)) linkTarget.value = previousTarget;
|
|
420
|
+
linkTarget.disabled = !candidates.length; linkAdd.disabled = !linkTarget.value;
|
|
421
|
+
for (const id of linked) {
|
|
422
|
+
const target = cards.find(value => value.id === id); if (!target) continue;
|
|
423
|
+
const row = element('li', 'link-row');
|
|
424
|
+
const remove = element('button', 'link-remove', '断开'); remove.type = 'button'; remove.dataset.linkId = id;
|
|
425
|
+
remove.setAttribute('aria-label', `断开与${target.title}的连线`);
|
|
426
|
+
remove.addEventListener('click', () => {
|
|
427
|
+
flushEditor(); const current = cards.find(value => value.id === selected), other = cards.find(value => value.id === resolveId(id));
|
|
428
|
+
if (!current || !other) return;
|
|
429
|
+
const before = snapshot(); setTool({ mode: 'select' });
|
|
430
|
+
const currentId = promoteCard(current.id), targetId = promoteCard(resolveId(id));
|
|
431
|
+
for (const value of cards) if (value.links) value.links = value.links.filter(link =>
|
|
432
|
+
!(value.id === currentId && link === targetId || value.id === targetId && link === currentId));
|
|
433
|
+
publish(before, '断开连线'); options.onSelect(selected); if (storageAvailable) announce('连线已断开,两条线索仍保留。');
|
|
434
|
+
});
|
|
435
|
+
row.append(element('span', 'link-title', target.title), remove); linkList.append(row);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function flushEditor() {
|
|
439
|
+
commitScale();
|
|
440
|
+
cancelSave(); const index = cards.findIndex(card => card.id === selected); if (index < 0) return;
|
|
441
|
+
const previous = cards[index], nextStage = Number(stage.value) as EvidenceCard['stage'];
|
|
442
|
+
const changed = title.value.trim().slice(0, EVIDENCE_TITLE_LIMIT) || previous.title;
|
|
443
|
+
const nextBody = body.value.slice(0, EVIDENCE_BODY_LIMIT), nextKind = kind.value as EvidenceCard['kind'];
|
|
444
|
+
if (previous.title === changed && previous.body === nextBody && previous.stage === nextStage && previous.kind === nextKind) return;
|
|
445
|
+
const before = snapshot(); promoteCard(previous.id);
|
|
446
|
+
const updated: EvidenceCard = { ...cards[index], contentRevision: editorRevision, title: changed, body: nextBody, stage: nextStage, kind: nextKind };
|
|
447
|
+
const layout = evidenceCardLayout(updated, index); updated.position = { x: layout.x, y: layout.y };
|
|
448
|
+
cards[index] = updated; selected = updated.id; publish(before, '编辑线索', `text:${updated.id}`); options.onSelect(selected); renderSize(updated); renderPaper(updated);
|
|
449
|
+
if (storageAvailable) announce(title.value.trim() ? '线索已保存到当前会话。' : '内容已保存,标题暂时保留原题。');
|
|
450
|
+
}
|
|
451
|
+
function select(id: string | null, openEditor = true) {
|
|
452
|
+
if (disposed) return;
|
|
453
|
+
const selectingCurrent = id !== null && id === selected; flushEditor();
|
|
454
|
+
if (selectingCurrent) id = selected; else if (id) id = resolveId(id);
|
|
455
|
+
selected = cards.some(card => card.id === id) ? id : null; history.seal();
|
|
456
|
+
renderOverview(); renderEditor(); options.onSelect(selected);
|
|
457
|
+
if (selected && openEditor) openTools(undefined, toolsOpen);
|
|
458
|
+
}
|
|
459
|
+
function addCard(card: EvidenceCard, position?: Point) {
|
|
460
|
+
if (disposed) return false;
|
|
461
|
+
flushEditor();
|
|
462
|
+
if (cards.length >= EVIDENCE_CARD_CAPACITY) { announce(`${EVIDENCE_CARD_CAPACITY} 张纸片已满,请先移除一条线索。`); return false; }
|
|
463
|
+
const before = snapshot();
|
|
464
|
+
const placed = position ? { ...card, position: clampEvidencePosition(card, cards.length, position.x, position.y) } : positionNewCard(card);
|
|
465
|
+
cards.push(placed); selected = card.id; setTool({ mode: 'select' });
|
|
466
|
+
publish(before, '添加线索'); renderEditor(); options.onSelect(selected); openTools();
|
|
467
|
+
if (active) title.focus({ preventScroll: true });
|
|
468
|
+
if (storageAvailable) announce('线索已钉上白板,可继续填写内容。'); return true;
|
|
469
|
+
}
|
|
470
|
+
function beginPlacement(template: EvidenceTemplate) {
|
|
471
|
+
if (cards.length >= EVIDENCE_CARD_CAPACITY) return;
|
|
472
|
+
flushEditor(); selected = null; renderEditor(); options.onSelect(null);
|
|
473
|
+
if (sceneUnavailable()) placeCard(template); else setTool({ mode: 'place', template });
|
|
474
|
+
}
|
|
475
|
+
function placeCard(template: EvidenceTemplate, position?: Point) {
|
|
476
|
+
if (position && (!Number.isFinite(position.x) || !Number.isFinite(position.y))) return false;
|
|
477
|
+
return addCard(createEvidenceTemplate(template), position);
|
|
478
|
+
}
|
|
479
|
+
function connectCards(fromId: string, toId: string) {
|
|
480
|
+
if (disposed) return false;
|
|
481
|
+
flushEditor(); fromId = resolveId(fromId); toId = resolveId(toId);
|
|
482
|
+
const from = cards.find(card => card.id === fromId), to = cards.find(card => card.id === toId);
|
|
483
|
+
if (!from || !to) { announce('线索已变化,请重新选择。'); return false; }
|
|
484
|
+
if (fromId === toId || connectedIds(from).has(toId)) {
|
|
485
|
+
announce(fromId === toId ? '请选择另一条线索。' : '这两条线索已经相连。'); return false;
|
|
486
|
+
}
|
|
487
|
+
const before = snapshot();
|
|
488
|
+
setTool({ mode: 'select' });
|
|
489
|
+
fromId = promoteCard(fromId); toId = promoteCard(resolveId(toId));
|
|
490
|
+
const current = cards.find(card => card.id === fromId)!;
|
|
491
|
+
current.links = [...(current.links || []), toId]; selected = toId;
|
|
492
|
+
publish(before, '连接线索'); renderEditor(); options.onSelect(selected); openTools(undefined, true);
|
|
493
|
+
if (storageAvailable) announce('两条线索已用红绳相连。'); return true;
|
|
494
|
+
}
|
|
495
|
+
function startConnection() {
|
|
496
|
+
flushEditor();
|
|
497
|
+
const card = cards.find(value => value.id === selected);
|
|
498
|
+
if (sceneUnavailable()) { connections.open = true; linkTarget.focus(); return false; }
|
|
499
|
+
if (cards.length < 2 || card && connectedIds(card).size >= cards.length - 1) {
|
|
500
|
+
announce('当前没有可连接的另一条线索。'); return false;
|
|
501
|
+
}
|
|
502
|
+
openTools(undefined, true);
|
|
503
|
+
setTool({ mode: 'connect', ...(card ? { fromId: card.id } : {}) }); return true;
|
|
504
|
+
}
|
|
505
|
+
function removeSelected() {
|
|
506
|
+
flushEditor(); if (!selected) return false;
|
|
507
|
+
setTool({ mode: 'select' }); const before = snapshot();
|
|
508
|
+
cards = cards.filter(card => card.id !== selected); selected = null;
|
|
509
|
+
publish(before, '移除线索'); renderEditor(); options.onSelect(null);
|
|
510
|
+
announce('线索已移除,可撤销本次操作。'); return true;
|
|
511
|
+
}
|
|
512
|
+
function duplicateSelected() {
|
|
513
|
+
flushEditor();
|
|
514
|
+
const index = cards.findIndex(value => value.id === selected); if (index < 0) return false;
|
|
515
|
+
if (cards.length >= EVIDENCE_CARD_CAPACITY) {
|
|
516
|
+
announce(`${EVIDENCE_CARD_CAPACITY} 张纸片已满,请先移除一条线索。`); return false;
|
|
517
|
+
}
|
|
518
|
+
setTool({ mode: 'select' });
|
|
519
|
+
const before = snapshot(), original = cards[index], layout = evidenceCardLayout(original, index);
|
|
520
|
+
const duplicate: EvidenceCard = { ...original, id: createEvidenceId(), links: [],
|
|
521
|
+
title: `${original.title.slice(0, EVIDENCE_TITLE_LIMIT - 3)} 副本` };
|
|
522
|
+
// Place a small offset toward the center so a copy is reachable even at a board edge.
|
|
523
|
+
duplicate.position = clampEvidencePosition(duplicate, cards.length,
|
|
524
|
+
layout.x + (layout.x > 0 ? -.4 : .4), layout.y + (layout.y > 0 ? -.35 : .35));
|
|
525
|
+
cards.push(duplicate); selected = duplicate.id;
|
|
526
|
+
publish(before, '复制线索'); renderEditor(); options.onSelect(selected); openTools(undefined, true);
|
|
527
|
+
announce('已复制纸片;原线索的连线保持原位。'); return true;
|
|
528
|
+
}
|
|
529
|
+
function resizeCard(id: string, value: number, x: number, y: number) {
|
|
530
|
+
if (disposed || ![value, x, y].every(Number.isFinite)) return;
|
|
531
|
+
flushEditor();
|
|
532
|
+
const index = cards.findIndex(card => card.id === resolveId(id)); if (index < 0) return;
|
|
533
|
+
const current = cards[index], scale = evidenceCardScale({ ...current, scale: value });
|
|
534
|
+
const position = clampEvidencePosition({ ...current, scale }, index, x, y);
|
|
535
|
+
if (Math.abs(evidenceCardScale(current) - scale) < 1e-6 &&
|
|
536
|
+
Math.abs((current.position?.x ?? evidenceCardLayout(current, index).x) - position.x) < 1e-6 &&
|
|
537
|
+
Math.abs((current.position?.y ?? evidenceCardLayout(current, index).y) - position.y) < 1e-6) return;
|
|
538
|
+
const before = snapshot(); promoteCard(current.id);
|
|
539
|
+
cards[index] = { ...cards[index], scale, position };
|
|
540
|
+
publish(before, '调整纸片大小'); renderEditor(); options.onSelect(selected);
|
|
541
|
+
if (storageAvailable) announce('纸片大小已保存。');
|
|
542
|
+
}
|
|
543
|
+
function handleShortcut(event: KeyboardEvent) {
|
|
544
|
+
if (disposed || !active || event.defaultPrevented || event.isComposing || event.altKey) return false;
|
|
545
|
+
const target = event.target;
|
|
546
|
+
if (target instanceof HTMLElement && (target.matches('input,textarea,select') || target.isContentEditable)) return false;
|
|
547
|
+
const key = event.key.toLowerCase(), command = event.ctrlKey || event.metaKey;
|
|
548
|
+
if (command) {
|
|
549
|
+
if (key === 'z') { if (event.shiftKey) redoLast(); else undoLast(); return true; }
|
|
550
|
+
if (key === 'y' && event.ctrlKey && !event.shiftKey) { redoLast(); return true; }
|
|
551
|
+
if (key === 'd' && !event.shiftKey && selected) { duplicateSelected(); return true; }
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
if (event.shiftKey || event.repeat) return false;
|
|
555
|
+
if ((key === 'delete' || key === 'backspace') && selected) return removeSelected();
|
|
556
|
+
if (key === 'l') { startConnection(); return true; }
|
|
557
|
+
if (key === 'n') { openTools(); beginPlacement('note'); return true; }
|
|
558
|
+
if (key === 'v') { setTool({ mode: 'select' }); return true; }
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
function setToolState(next: EvidenceBoardTool) {
|
|
562
|
+
if (disposed) return;
|
|
563
|
+
tool = next.mode === 'connect' && next.fromId ? { ...next, fromId: resolveId(next.fromId) } : { ...next };
|
|
564
|
+
modeRow.hidden = tool.mode === 'select';
|
|
565
|
+
modeText.textContent = tool.mode === 'place' ? `在板面点击,放下一张${TEMPLATE_NAMES[tool.template]}`
|
|
566
|
+
: tool.mode === 'connect' && tool.fromId ? '再点一条线索,完成连线' : '先点选一条线索,作为连线起点';
|
|
567
|
+
floating.dataset.tool = tool.mode;
|
|
568
|
+
if (tool.mode !== 'select') { editorCollapsed = false; renderCollapsed(); }
|
|
569
|
+
for (const button of templateButtons) button.setAttribute('aria-pressed', String(tool.mode === 'place' && tool.template === button.dataset.template));
|
|
570
|
+
connectButton.setAttribute('aria-pressed', String(tool.mode === 'connect'));
|
|
571
|
+
if (toolsOpen) positionTools();
|
|
572
|
+
}
|
|
573
|
+
function setTool(next: EvidenceBoardTool) { setToolState(next); options.onToolChange({ ...tool }); }
|
|
574
|
+
function layoutBounds() {
|
|
575
|
+
const rect = host.getBoundingClientRect(), rootRect = root?.getBoundingClientRect() || rect;
|
|
576
|
+
const scale = rect.width / (host.clientWidth || rect.width) || 1;
|
|
577
|
+
const visual = window.visualViewport;
|
|
578
|
+
const left = Math.max(rect.left, rootRect.left, visual?.offsetLeft || 0), top = Math.max(rect.top, rootRect.top, visual?.offsetTop || 0);
|
|
579
|
+
const right = Math.min(rect.right, rootRect.right, (visual?.offsetLeft || 0) + (visual?.width || innerWidth));
|
|
580
|
+
const bottom = Math.min(rect.bottom, rootRect.bottom, (visual?.offsetTop || 0) + (visual?.height || innerHeight));
|
|
581
|
+
return { rect, scale, left: (left - rect.left) / scale, top: (top - rect.top) / scale,
|
|
582
|
+
right: (right - rect.left) / scale, bottom: (bottom - rect.top) / scale,
|
|
583
|
+
width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
|
|
584
|
+
}
|
|
585
|
+
function positionTools(anchor?: Point) {
|
|
586
|
+
if (!toolsOpen || disposed) return;
|
|
587
|
+
const bounds = layoutBounds(), narrow = host.dataset.layout === 'portrait' || bounds.width < 620;
|
|
588
|
+
const safe = narrow ? 12 : 16, width = Math.min(selected && !editorCollapsed ? 404 : 316, Math.max(160, bounds.width - safe * 2));
|
|
589
|
+
floating.classList.toggle('is-drawer', narrow);
|
|
590
|
+
floating.style.width = `${narrow ? Math.max(160, bounds.width - 24) : width}px`;
|
|
591
|
+
floating.style.maxHeight = `${Math.max(120, Math.min(narrow ? bounds.height * .68 : 740, bounds.height - safe * 2))}px`;
|
|
592
|
+
floating.style.transform = `scale(${1 / bounds.scale})`;
|
|
593
|
+
fitPaperText();
|
|
594
|
+
const height = floating.offsetHeight / bounds.scale, actualWidth = floating.offsetWidth / bounds.scale;
|
|
595
|
+
if (narrow) {
|
|
596
|
+
floating.style.left = `${bounds.left + safe / bounds.scale}px`;
|
|
597
|
+
floating.style.top = `${Math.max(bounds.top + safe / bounds.scale, bounds.bottom - height - safe / bounds.scale)}px`;
|
|
598
|
+
reportViewportInset(Math.min(1, (floating.offsetHeight + safe) / bounds.height));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
reportViewportInset(0);
|
|
602
|
+
if (selected && selected !== positionedCard) {
|
|
603
|
+
const cardBounds = options.getCardBounds?.(selected);
|
|
604
|
+
if (cardBounds) {
|
|
605
|
+
const gap = 22 / bounds.scale;
|
|
606
|
+
const right = cardBounds.right + gap, left = cardBounds.left - actualWidth - gap;
|
|
607
|
+
toolPosition = { x: right + actualWidth <= bounds.right ? right : left >= bounds.left ? left : bounds.right - actualWidth - gap,
|
|
608
|
+
y: cardBounds.top - 50 / bounds.scale };
|
|
609
|
+
}
|
|
610
|
+
positionedCard = selected;
|
|
611
|
+
}
|
|
612
|
+
if (!toolPosition) toolPosition = anchor ? { x: anchor.x + 20 / bounds.scale, y: anchor.y - height - 20 / bounds.scale }
|
|
613
|
+
: { x: bounds.right - actualWidth - 24 / bounds.scale, y: bounds.top + 96 / bounds.scale };
|
|
614
|
+
toolPosition.x = Math.max(bounds.left + safe / bounds.scale, Math.min(bounds.right - actualWidth - safe / bounds.scale, toolPosition.x));
|
|
615
|
+
toolPosition.y = Math.max(bounds.top + safe / bounds.scale, Math.min(bounds.bottom - height - safe / bounds.scale, toolPosition.y));
|
|
616
|
+
floating.style.left = `${toolPosition.x}px`; floating.style.top = `${toolPosition.y}px`;
|
|
617
|
+
}
|
|
618
|
+
function reportViewportInset(value: number) {
|
|
619
|
+
if (Math.abs(viewportInset - value) < .0001) return;
|
|
620
|
+
viewportInset = value; options.onViewportInset?.(value);
|
|
621
|
+
}
|
|
622
|
+
function renderCollapsed() {
|
|
623
|
+
const card = cards.find(value => value.id === selected);
|
|
624
|
+
floating.classList.toggle('is-collapsed', editorCollapsed);
|
|
625
|
+
headingText.querySelector('span')!.textContent = card ? 'EVIDENCE / 线索纸片' : 'BOARD / 调查板';
|
|
626
|
+
headingText.querySelector('strong')!.textContent = editorCollapsed && card ? card.title : card ? '在纸片上编辑' : '整理线索';
|
|
627
|
+
headingText.querySelector('strong')!.title = editorCollapsed && card ? card.title : '';
|
|
628
|
+
collapseButton.textContent = editorCollapsed ? '展开' : '收起';
|
|
629
|
+
collapseButton.setAttribute('aria-label', editorCollapsed ? '展开整理工具' : '收起工具,保留线索选中');
|
|
630
|
+
collapseButton.setAttribute('aria-expanded', String(!editorCollapsed));
|
|
631
|
+
}
|
|
632
|
+
function minimizeTools() {
|
|
633
|
+
if (!toolsOpen || disposed) return;
|
|
634
|
+
flushEditor(); setTool({ mode: 'select' }); stopWindowDrag();
|
|
635
|
+
if (floating.contains(document.activeElement)) (document.activeElement as HTMLElement).blur();
|
|
636
|
+
editorCollapsed = true; renderCollapsed(); positionTools();
|
|
637
|
+
}
|
|
638
|
+
function expandTools() { editorCollapsed = false; renderCollapsed(); positionTools(); }
|
|
639
|
+
function openTools(position?: Point, preserveCollapsed = false) {
|
|
640
|
+
if (disposed || !active) return;
|
|
641
|
+
const wasOpen = toolsOpen;
|
|
642
|
+
if (!preserveCollapsed) { editorCollapsed = false; renderCollapsed(); }
|
|
643
|
+
toolsOpen = true; floating.hidden = false; directoryOpen = false; renderDirectoryVisibility();
|
|
644
|
+
positionTools(position); if (!wasOpen) options.onToolsOpen?.(true);
|
|
645
|
+
}
|
|
646
|
+
function stopWindowDrag() {
|
|
647
|
+
const previous = windowDrag; windowDrag = null; floating.classList.remove('is-dragging');
|
|
648
|
+
if (previous && heading.hasPointerCapture(previous.id)) heading.releasePointerCapture(previous.id);
|
|
649
|
+
}
|
|
650
|
+
function closeTools() {
|
|
651
|
+
if (disposed) return;
|
|
652
|
+
flushEditor(); stopWindowDrag(); setTool({ mode: 'select' });
|
|
653
|
+
positionedCard = null;
|
|
654
|
+
const wasOpen = toolsOpen; toolsOpen = false; editorCollapsed = false; floating.hidden = true;
|
|
655
|
+
reportViewportInset(0);
|
|
656
|
+
if (floating.contains(document.activeElement)) (document.activeElement as HTMLElement).blur();
|
|
657
|
+
selected = null; renderOverview(); renderEditor(); options.onSelect(null);
|
|
658
|
+
if (wasOpen) options.onToolsOpen?.(false);
|
|
659
|
+
}
|
|
660
|
+
function handleEscape() {
|
|
661
|
+
if (!active || disposed) return false;
|
|
662
|
+
if (tool.mode !== 'select') { setTool({ mode: 'select' }); return true; }
|
|
663
|
+
if (toolsOpen) { closeTools(); return true; }
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
heading.addEventListener('pointerdown', event => {
|
|
667
|
+
if (event.button !== 0 || event.target instanceof Element && event.target.closest('button') || floating.classList.contains('is-drawer')) return;
|
|
668
|
+
const bounds = layoutBounds(); event.preventDefault();
|
|
669
|
+
windowDrag = { id: event.pointerId, x: event.clientX, y: event.clientY, start: { ...(toolPosition || { x: floating.offsetLeft, y: floating.offsetTop }) }, scale: bounds.scale };
|
|
670
|
+
heading.setPointerCapture(event.pointerId); floating.classList.add('is-dragging');
|
|
671
|
+
}, { signal: events.signal });
|
|
672
|
+
heading.addEventListener('pointermove', event => {
|
|
673
|
+
if (!windowDrag || event.pointerId !== windowDrag.id) return;
|
|
674
|
+
toolPosition = { x: windowDrag.start.x + (event.clientX - windowDrag.x) / windowDrag.scale,
|
|
675
|
+
y: windowDrag.start.y + (event.clientY - windowDrag.y) / windowDrag.scale }; positionTools();
|
|
676
|
+
}, { signal: events.signal });
|
|
677
|
+
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture'] as const) heading.addEventListener(name, stopWindowDrag, { signal: events.signal });
|
|
678
|
+
window.addEventListener('blur', stopWindowDrag, { signal: events.signal });
|
|
679
|
+
editor.addEventListener('submit', event => { event.preventDefault(); flushEditor(); if (storageAvailable && title.value.trim()) announce('线索已保存到当前会话。'); }, { signal: events.signal });
|
|
680
|
+
for (const input of [title, body]) input.addEventListener('input', () => { fitPaperText(); cancelSave(); saveTimer = setTimeout(flushEditor, 300); }, { signal: events.signal });
|
|
681
|
+
for (const input of [stage, kind]) input.addEventListener('change', flushEditor, { signal: events.signal });
|
|
682
|
+
title.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); body.focus(); } }, { signal: events.signal });
|
|
683
|
+
sizeRange.addEventListener('pointerdown', () => { scalePointerActive = true; }, { signal: events.signal });
|
|
684
|
+
const releaseSize = () => { scalePointerActive = false; commitScale(); };
|
|
685
|
+
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture'] as const) {
|
|
686
|
+
sizeRange.addEventListener(name, releaseSize, { signal: events.signal });
|
|
687
|
+
}
|
|
688
|
+
window.addEventListener('blur', releaseSize, { signal: events.signal });
|
|
689
|
+
sizeRange.addEventListener('input', () => resizeCurrent(Number(sizeRange.value)), { signal: events.signal });
|
|
690
|
+
sizeRange.addEventListener('change', commitScale, { signal: events.signal });
|
|
691
|
+
sizeRange.addEventListener('blur', releaseSize, { signal: events.signal });
|
|
692
|
+
window.addEventListener('pagehide', flushEditor, { signal: events.signal });
|
|
693
|
+
const sceneObserver = new MutationObserver(() => { renderDirectoryVisibility(); positionTools(); });
|
|
694
|
+
if (root) sceneObserver.observe(root, { attributes: true, attributeFilter: ['class'] });
|
|
695
|
+
sceneObserver.observe(host, { attributes: true, attributeFilter: ['style', 'data-layout'] });
|
|
696
|
+
const resizeObserver = new ResizeObserver(() => { if (windowDrag) stopWindowDrag(); positionTools(); });
|
|
697
|
+
resizeObserver.observe(host); resizeObserver.observe(floating);
|
|
698
|
+
window.visualViewport?.addEventListener('resize', () => positionTools(), { signal: events.signal });
|
|
699
|
+
window.visualViewport?.addEventListener('scroll', () => positionTools(), { signal: events.signal });
|
|
700
|
+
load(); setToolState({ mode: 'select' });
|
|
701
|
+
return {
|
|
702
|
+
setActive(value: boolean) {
|
|
703
|
+
if (disposed || active === value) return;
|
|
704
|
+
if (!value) closeTools(); active = value; panel.hidden = !value;
|
|
705
|
+
if (value && selected) openTools();
|
|
706
|
+
},
|
|
707
|
+
setSession(id: string) {
|
|
708
|
+
if (disposed || sessionId === id) return;
|
|
709
|
+
closeTools(); sessionId = id; selected = null; directoryOpen = false; history.clear(); promotedIds.clear(); load();
|
|
710
|
+
},
|
|
711
|
+
select, openTools, closeTools, handleEscape, handleShortcut, minimizeTools, setToolState, placeCard, connectCards, resizeCard,
|
|
712
|
+
toggleTools(position?: Point) {
|
|
713
|
+
if (toolsOpen && editorCollapsed) expandTools();
|
|
714
|
+
else if (toolsOpen) closeTools(); else openTools(position);
|
|
715
|
+
},
|
|
716
|
+
addSource(source: { id: string; title: string; excerpt: string }) {
|
|
717
|
+
if (disposed) return;
|
|
718
|
+
const previous = cards.find(card => card.sourceId === source.id);
|
|
719
|
+
if (previous) { select(previous.id); announce('这份资料已经在证据板上。'); return; }
|
|
720
|
+
addCard({ ...createEvidenceTemplate('source'), title: source.title.trim().slice(0, EVIDENCE_TITLE_LIMIT) || '资料摘录',
|
|
721
|
+
body: source.excerpt.slice(0, EVIDENCE_BODY_LIMIT), sourceId: source.id.slice(0, 1024), sourceTitle: source.title.slice(0, 512) });
|
|
722
|
+
},
|
|
723
|
+
moveCard(id: string, x: number, y: number) {
|
|
724
|
+
if (disposed || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
725
|
+
flushEditor(); const index = cards.findIndex(card => card.id === resolveId(id)); if (index < 0) return;
|
|
726
|
+
const card = cards[index], position = clampEvidencePosition(card, index, x, y);
|
|
727
|
+
if (card.position?.x === position.x && card.position?.y === position.y) return;
|
|
728
|
+
const before = snapshot(); promoteCard(card.id); card.position = position;
|
|
729
|
+
publish(before, '移动线索'); options.onSelect(selected); if (storageAvailable) announce('纸片位置已保存。');
|
|
730
|
+
},
|
|
731
|
+
replaceCards(next: EvidenceCard[], reset = false) {
|
|
732
|
+
cards = cloneCards(next);
|
|
733
|
+
const editing = cards.find(card => card.id === selected);
|
|
734
|
+
if (editing && title.value === editing.title && body.value === editing.body) editorRevision = editing.contentRevision;
|
|
735
|
+
if (reset) { history.clear(); selected = null; closeTools(); }
|
|
736
|
+
if (selected && !cards.some(c => c.id === selected)) selected = null;
|
|
737
|
+
renderOverview(); renderLinks(); renderUndo();
|
|
738
|
+
// Preserve an in-progress form draft when a background receipt arrives.
|
|
739
|
+
if (!floating.contains(document.activeElement)) renderEditor();
|
|
740
|
+
},
|
|
741
|
+
remapIds(mapping: Record<string,string>, revisions: Record<string, { content_revision: number; layout_revision: number }> = {}) {
|
|
742
|
+
history.remapIds(mapping, revisions); cards = cards.map(card => {
|
|
743
|
+
const id = mapping[card.id] || card.id, revision = id !== card.id ? revisions[id] : undefined;
|
|
744
|
+
// A copied card inherits the original revision until its own creation is
|
|
745
|
+
// acknowledged. Its open draft and undo history now belong to the new ID.
|
|
746
|
+
if (revision && selected === card.id) editorRevision = revision.content_revision;
|
|
747
|
+
return { ...card, id, ...(revision ? { contentRevision: revision.content_revision, layoutRevision: revision.layout_revision } : {}), links: card.links?.map(id => mapping[id] || id) };
|
|
748
|
+
});
|
|
749
|
+
if (selected) selected = mapping[selected] || selected;
|
|
750
|
+
},
|
|
751
|
+
getCards: copyCards,
|
|
752
|
+
dispose() {
|
|
753
|
+
if (disposed) return;
|
|
754
|
+
flushEditor(); stopWindowDrag(); disposed = true; cancelSave(); events.abort(); sceneObserver.disconnect(); resizeObserver.disconnect(); panel.remove(); floating.remove();
|
|
755
|
+
},
|
|
756
|
+
stats() { return { cards: cards.length, byStage: EVIDENCE_STAGES.map((_, index) => cards.filter(card => card.stage === index).length),
|
|
757
|
+
selected, active, links: cards.reduce((sum, card) => sum + (card.links?.length || 0), 0),
|
|
758
|
+
examples: cards.filter(isPreviewCard).length, storageAvailable, toolsOpen, editorCollapsed, viewportInset, tool: { ...tool },
|
|
759
|
+
canUndo: Boolean(history.stats().undoCount) || scaleDirty, canRedo: Boolean(history.stats().redoCount),
|
|
760
|
+
undoCount: history.stats().undoCount, redoCount: history.stats().redoCount }; },
|
|
761
|
+
};
|
|
762
|
+
}
|