From ca3de36c99b2f1dcc9e6c3ce7b0fe99e009a9bda Mon Sep 17 00:00:00 2001 From: Mahiru Date: Fri, 29 May 2026 02:55:05 +0800 Subject: [PATCH] feat: virtual list for graphical editor --- packages/origine2/package.json | 3 +- .../GraphicalEditor/GraphicalEditor.tsx | 298 ++++++++++-------- .../graphicalEditor.module.scss | 22 ++ yarn.lock | 12 + 4 files changed, 206 insertions(+), 129 deletions(-) diff --git a/packages/origine2/package.json b/packages/origine2/package.json index ce7ed185..0f28ecc0 100644 --- a/packages/origine2/package.json +++ b/packages/origine2/package.json @@ -35,9 +35,10 @@ "@icon-park/react": "^1.4.2", "@lingui/macro": "^4.8.0", "@lingui/react": "^4.8.0", - "@webgal/editor-preview-protocol": "1.0.0", "@monaco-editor/react": "^4.4.5", + "@tanstack/react-virtual": "^3.13.26", "@uiw/react-json-view": "^2.0.0-alpha.12", + "@webgal/editor-preview-protocol": "1.0.0", "axios": "^1.12.0", "classnames": "^2.5.1", "cloudlogjs": "^1.0.11", diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx index 10f8bcb1..232f5eba 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx @@ -1,6 +1,7 @@ import { parseScene } from "./parser"; import axios from "axios"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { EditorPreviewClient } from "../../../utils/editorPreviewClient"; import { mergeToString, splitToArray } from "./utils/sceneTextProcessor"; import styles from "./graphicalEditor.module.scss"; @@ -18,7 +19,7 @@ import { api } from "@/api"; import { GlobalTerrePanel } from "./components/TerrePanel"; import { getArgByKey } from "./utils/getArgByKey"; -import type { DropResult } from '@hello-pangea/dnd'; +import type { DragStart, DraggableProvided, DropResult } from '@hello-pangea/dnd'; interface IGraphicalEditorProps { targetPath: string; @@ -31,6 +32,24 @@ interface SentenceItem { show: boolean; } +interface SentenceRowProps { + sentence: ISentence; + sentenceItem: SentenceItem; + index: number; + linkedWithPrevious: boolean; + targetPath: string; + sceneLabels: string[]; + onAddBefore: (titleText: string, insertIndex: number) => void; + onDelete: (index: number) => void; + onSync: (index: number) => void; + onToggleShow: (index: number) => void; + onUpdate: (newContent: string, updateIndex: number) => void; +} + +interface SentenceRowContentProps extends SentenceRowProps { + provided: DraggableProvided; +} + const inlineArgOptionCommands = new Set([ commandType.changeBg, commandType.changeFigure, @@ -43,6 +62,8 @@ const inlineArgOptionCommands = new Set([ export default function GraphicalEditor(props: IGraphicalEditorProps) { const [sentenceData, setSentenceData] = useState([]); const sentenceDataRef = useRef([]); + const scrollElementRef = useRef(null); + const [draggingId, setDraggingId] = useState(null); const [addSentenceDialog, setAddSentenceDialog] = useState<{ titleText: string; insertIndex: number; @@ -173,7 +194,12 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { submitScene(newSentences, endIndex); }, [submitScene, updateSentenceData]); + const onDragStart = useCallback((start: DragStart) => { + setDraggingId(start.draggableId); + }, []); + const onDragEnd = useCallback((result: DropResult) => { + setDraggingId(null); if (!result.destination) { return; } @@ -219,22 +245,6 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { }; }, [syncCurrentLine]); - useEffect(() => { - const targetLine = editorLineHolder.getSceneLine(props.targetPath); - const scrollToFunc = () => { - const targetBlock = document.querySelector(`.sentence-block-${targetLine}`); - if (targetBlock) { - targetBlock?.scrollIntoView?.({ behavior: 'auto' }); - } else { - console.log('Retry scroll to in 50ms'); - setTimeout(() => scrollToFunc(), 50); - } - }; - if (targetLine > 3) { - scrollToFunc(); - } - }, [props.targetPath]); - const addNewSentenceAttach = useCallback((sentence: string) => { addOneSentence(sentence, sentenceDataRef.current.length); }, [addOneSentence]); @@ -275,6 +285,21 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { .filter(Boolean), [parsedScene] ); + const rowVirtualizer = useVirtualizer({ + count: parsedScene.sentenceList.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: (index) => sentenceData[index]?.show ? 120 : 48, + getItemKey: (index) => sentenceData[index]?.id ?? index, + overscan: 8, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + + useEffect(() => { + const targetLine = editorLineHolder.getSceneLine(props.targetPath); + if (targetLine > 3 && targetLine <= parsedScene.sentenceList.length) { + rowVirtualizer.scrollToIndex(targetLine - 1, { align: 'start' }); + } + }, [parsedScene.sentenceList.length, props.targetPath, rowVirtualizer]); useEffect(() => { const handleDragUpdate = (data: any) => { @@ -290,49 +315,76 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { eventBus.off('editor:drag-update-scene', handleDragUpdate); }; }, [fetchScene]); + + const getSentenceRowProps = (i: number): SentenceRowProps | null => { + const sentence = parsedScene.sentenceList[i]; + const sentenceItem = sentenceData[i]; + if (!sentence || !sentenceItem) return null; + return { + sentence, + sentenceItem, + index: i, + linkedWithPrevious: i > 0 && getArgByKey(parsedScene.sentenceList[i - 1], "next") === true, + targetPath: props.targetPath, + sceneLabels, + onAddBefore: openAddSentenceDialog, + onDelete: deleteOneSentence, + onSync: syncToIndex, + onToggleShow: changeShowSentence, + onUpdate: updateSentenceByIndex, + }; + }; + + const renderSentenceRow = (provided: DraggableProvided, i: number) => { + const rowProps = getSentenceRowProps(i); + return rowProps && ; + }; + return
-
- - - {(provided) => ( - // 下面开始书写容器 -
- {parsedScene.sentenceList.map((sentence, i) => { - const sentenceItem = sentenceData[i]; - if (!sentenceItem) return null; - return 0 && getArgByKey(parsedScene.sentenceList[i - 1], "next") === true} - targetPath={props.targetPath} - sceneLabels={sceneLabels} - onAddBefore={openAddSentenceDialog} - onDelete={deleteOneSentence} - onSync={syncToIndex} - onToggleShow={changeShowSentence} - onUpdate={updateSentenceByIndex} - />; + + renderSentenceRow(provided, rubric.source.index)} + > + {(provided) => ( +
{ + scrollElementRef.current = element; + provided.innerRef(element); + }} + > +
+ {virtualRows.map((virtualRow) => { + const rowProps = getSentenceRowProps(virtualRow.index); + if (!rowProps) return null; + return
+ +
; })} - {provided.placeholder} -
- openAddSentenceDialog(t`添加语句`, sentenceData.length)} - /> -
- )} - - -
+
+ openAddSentenceDialog(t`添加语句`, sentenceData.length)} + /> +
+
+ )} +
+
void; - onDelete: (index: number) => void; - onSync: (index: number) => void; - onToggleShow: (index: number) => void; - onUpdate: (newContent: string, updateIndex: number) => void; -}) => { - const { sentence, sentenceItem, index: i, linkedWithPrevious, targetPath, sceneLabels } = props; +const SentenceRowContent = (props: SentenceRowContentProps) => { + const { provided, sentence, sentenceItem, index: i, linkedWithPrevious, targetPath, sceneLabels } = props; const index = i + 1; const sentenceConfig = sentenceEditorConfig.find((e) => e.type === sentence.command) ?? sentenceEditorDefault; const SentenceEditor = sentenceConfig.component; @@ -375,71 +415,73 @@ const SentenceRow = memo((props: { />; const inlineArgOption = inlineArgOptionCommands.has(sentence.command); - return - {(provided) => ( -
-
- {linkedWithPrevious &&
} -
-
- props.onAddBefore(t`本句前插入句子`, i)} - /> -
-
+ return
+
+ {linkedWithPrevious &&
} +
+
+ props.onAddBefore(t`本句前插入句子`, i)} + />
-
-
{index} - +
+
+
+
{index} + +
+
+
+
+ {sentenceConfig.title()}
-
-
-
- {sentenceConfig.title()} -
-
props.onToggleShow(i)}> - {sentenceItem.show ? - : - } +
props.onToggleShow(i)}> + {sentenceItem.show ? + : + } +
+
+
props.onDelete(i)}> + +
+ {t`删除本句`}
-
-
props.onDelete(i)}> - -
- {t`删除本句`} -
-
-
props.onSync(i)}> - -
- {t`执行到此句`} -
-
+
+
props.onSync(i)}> + +
+ {t`执行到此句`}
- {sentenceItem.show &&
- { - props.onUpdate(newSentence, i); - }} targetPath={targetPath} sceneLabels={sceneLabels} extraOptions={inlineArgOption ? argOption : undefined} /> - {!inlineArgOption && argOption} -
}
+ {sentenceItem.show &&
+ { + props.onUpdate(newSentence, i); + }} targetPath={targetPath} sceneLabels={sceneLabels} extraOptions={inlineArgOption ? argOption : undefined} /> + {!inlineArgOption && argOption} +
}
- )} +
+
; +}; + +const SentenceRow = memo((props: SentenceRowProps) => { + return + {(provided) => } ; }, (prev, next) => ( prev.sentence === next.sentence && diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/graphicalEditor.module.scss b/packages/origine2/src/pages/editor/GraphicalEditor/graphicalEditor.module.scss index facdb27d..1ad26ae0 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/graphicalEditor.module.scss +++ b/packages/origine2/src/pages/editor/GraphicalEditor/graphicalEditor.module.scss @@ -2,6 +2,28 @@ height: 100%; display: flex; flex-flow: column; + min-height: 0; + overflow: hidden; +} + +.virtualScroller { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px 4px 0 4px; + box-sizing: border-box; +} + +.virtualCanvas { + position: relative; + width: 100%; +} + +.virtualItem { + position: absolute; + top: 0; + left: 0; + width: 100%; } .sentenceEditorWrapper { diff --git a/yarn.lock b/yarn.lock index 783ac329..82b4ed7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4866,6 +4866,18 @@ dependencies: tslib "^2.8.0" +"@tanstack/react-virtual@^3.13.26": + version "3.13.26" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz#1a0be9837ddc09c06868f9138fb792ab29fedc30" + integrity sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ== + dependencies: + "@tanstack/virtual-core" "3.16.0" + +"@tanstack/virtual-core@3.16.0": + version "3.16.0" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz#987bd975c1a486a38ff33eb0634f70436305570f" + integrity sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A== + "@tokenizer/inflate@^0.2.6": version "0.2.7" resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.2.7.tgz#32dd9dfc9abe457c89b3d9b760fc0690c85a103b"