Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/origine2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
298 changes: 170 additions & 128 deletions packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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;
}
Comment on lines +49 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add isDraggingPlaceholder to SentenceRowContentProps to allow hiding the content of the original item in the list while it is being dragged.

Suggested change
interface SentenceRowContentProps extends SentenceRowProps {
provided: DraggableProvided;
}
interface SentenceRowContentProps extends SentenceRowProps {
provided: DraggableProvided;
isDraggingPlaceholder: boolean;
}


const inlineArgOptionCommands = new Set<commandType>([
commandType.changeBg,
commandType.changeFigure,
Expand All @@ -43,6 +62,8 @@ const inlineArgOptionCommands = new Set<commandType>([
export default function GraphicalEditor(props: IGraphicalEditorProps) {
const [sentenceData, setSentenceData] = useState<SentenceItem[]>([]);
const sentenceDataRef = useRef<SentenceItem[]>([]);
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [addSentenceDialog, setAddSentenceDialog] = useState<{
titleText: string;
insertIndex: number;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -275,6 +285,21 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) {
.filter(Boolean),
[parsedScene]
);
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
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]);
Comment on lines +297 to +302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current scroll-to-target-line logic triggers as soon as parsedScene.sentenceList is loaded. However, at this point, sentenceData might not be fully synchronized yet. If the scroll occurs while sentenceData is empty or incomplete, the virtualizer will use the default/collapsed height (48px) to calculate the scroll offset. Once sentenceData loads and items expand to their actual heights (e.g., 120px), the target line will be pushed down and out of view.

To fix this, we should use a ref to track the last scrolled path and only trigger the scroll once sentenceData is fully synchronized with parsedScene.sentenceList.

  const lastScrolledPathRef = useRef<string | null>(null);

  useEffect(() => {
    const targetLine = editorLineHolder.getSceneLine(props.targetPath);
    const isDataReady = sentenceData.length === parsedScene.sentenceList.length && sentenceData.length > 0;

    if (isDataReady && lastScrolledPathRef.current !== props.targetPath) {
      if (targetLine > 3 && targetLine <= parsedScene.sentenceList.length) {
        rowVirtualizer.scrollToIndex(targetLine - 1, { align: 'start' });
        lastScrolledPathRef.current = props.targetPath;
      }
    }
  }, [parsedScene.sentenceList.length, props.targetPath, sentenceData, rowVirtualizer]);


useEffect(() => {
const handleDragUpdate = (data: any) => {
Expand All @@ -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 && <SentenceRowContent provided={provided} {...rowProps} />;
};
Comment on lines +338 to +341

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When rendering the clone for the dragging item under the cursor, we should pass isDraggingPlaceholder={false} so that the full content of the card is visible while dragging.

Suggested change
const renderSentenceRow = (provided: DraggableProvided, i: number) => {
const rowProps = getSentenceRowProps(i);
return rowProps && <SentenceRowContent provided={provided} {...rowProps} />;
};
const renderSentenceRow = (provided: DraggableProvided, i: number) => {
const rowProps = getSentenceRowProps(i);
return rowProps && <SentenceRowContent provided={provided} isDraggingPlaceholder={false} {...rowProps} />;
};


return <div className={styles.main} id="graphical-editor-main">
<div style={{ flex: 1, padding: '14px 4px 0 4px' }}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
// 下面开始书写容器
<div style={{ height: "100%" }}
// provided.droppableProps应用的相同元素.
{...provided.droppableProps}
// 为了使 droppable 能够正常工作必须 绑定到最高可能的DOM节点中provided.innerRef.
ref={provided.innerRef}
>
{parsedScene.sentenceList.map((sentence, i) => {
const sentenceItem = sentenceData[i];
if (!sentenceItem) return null;
return <SentenceRow
key={sentenceItem.id}
sentence={sentence}
sentenceItem={sentenceItem}
index={i}
linkedWithPrevious={i > 0 && getArgByKey(parsedScene.sentenceList[i - 1], "next") === true}
targetPath={props.targetPath}
sceneLabels={sceneLabels}
onAddBefore={openAddSentenceDialog}
onDelete={deleteOneSentence}
onSync={syncToIndex}
onToggleShow={changeShowSentence}
onUpdate={updateSentenceByIndex}
/>;
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<Droppable
droppableId="droppable"
mode="virtual"
renderClone={(provided, _, rubric) => renderSentenceRow(provided, rubric.source.index)}
>
{(provided) => (
<div
className={styles.virtualScroller}
{...provided.droppableProps}
ref={(element) => {
scrollElementRef.current = element;
provided.innerRef(element);
}}
>
<div className={styles.virtualCanvas} style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{virtualRows.map((virtualRow) => {
const rowProps = getSentenceRowProps(virtualRow.index);
if (!rowProps) return null;
return <div
key={rowProps.sentenceItem.id}
className={styles.virtualItem}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
transform: `translateY(${virtualRow.start}px)`,
height: rowProps.sentenceItem.id === draggingId ? `${virtualRow.size}px` : undefined,
}}
>
<SentenceRow {...rowProps} />
</div>;
})}
{provided.placeholder}
<div className={styles.addWrapper}>
<AddSentenceButton
titleText={t`添加语句`}
type={addSentenceType.backward}
onClick={() => openAddSentenceDialog(t`添加语句`, sentenceData.length)}
/>
</div>
</div>
)}
</Droppable>
</DragDropContext>
</div>
<div className={styles.addWrapper}>
<AddSentenceButton
titleText={t`添加语句`}
type={addSentenceType.backward}
onClick={() => openAddSentenceDialog(t`添加语句`, sentenceData.length)}
/>
</div>
</div>
)}
</Droppable>
</DragDropContext>
<AddSentenceDialog
open={!!addSentenceDialog}
titleText={addSentenceDialog?.titleText ?? ""}
Expand All @@ -345,20 +397,8 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) {

const StableGlobalTerrePanel = memo(GlobalTerrePanel);

const SentenceRow = memo((props: {
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;
}) => {
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;
Comment on lines +400 to 404

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When an item is being dragged in a virtual list, @hello-pangea/dnd renders a clone under the cursor, but the original item still remains in the list. If we don't hide its content, a duplicate of the dragging item will remain visible in its original position.

We should check isDraggingPlaceholder and render an empty placeholder container with the drag handle props to preserve the space without showing duplicate content.

const SentenceRowContent = (props: SentenceRowContentProps) => {
  const { provided, isDraggingPlaceholder, sentence, sentenceItem, index: i, linkedWithPrevious, targetPath, sceneLabels } = props;
  const index = i + 1;

  if (isDraggingPlaceholder) {
    return <div ref={provided.innerRef} {...provided.draggableProps} style={provided.draggableProps.style} />;
  }

  const sentenceConfig = sentenceEditorConfig.find((e) => e.type === sentence.command) ?? sentenceEditorDefault;
  const SentenceEditor = sentenceConfig.component;

Expand All @@ -375,71 +415,73 @@ const SentenceRow = memo((props: {
/>;
const inlineArgOption = inlineArgOptionCommands.has(sentence.command);

return <Draggable key={sentenceItem.id} draggableId={sentenceItem.id} index={i}>
{(provided) => (
<div className={`${styles.sentenceEditorWrapper} sentence-block-${index}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className={styles.addForwardArea}>
{linkedWithPrevious && <div className={styles.nextChain} title={t`由上一句的 next 连续执行`}><LinkOne theme="outline" size="20" strokeWidth={3} /></div>}
<div className={styles.addForwardAreaButtonGroup}>
<div className={styles.addForwardAreaButton}>
<AddSentenceButton
titleText={t`本句前插入句子`}
type={addSentenceType.forward}
onClick={() => props.onAddBefore(t`本句前插入句子`, i)}
/>
</div>
</div>
return <div className={`${styles.sentenceEditorWrapper} sentence-block-${index}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className={styles.addForwardArea}>
{linkedWithPrevious && <div className={styles.nextChain} title={t`由上一句的 next 连续执行`}><LinkOne theme="outline" size="20" strokeWidth={3} /></div>}
<div className={styles.addForwardAreaButtonGroup}>
<div className={styles.addForwardAreaButton}>
<AddSentenceButton
titleText={t`本句前插入句子`}
type={addSentenceType.forward}
onClick={() => props.onAddBefore(t`本句前插入句子`, i)}
/>
</div>
<div className={styles.sentenceEditorContent}>
<div className={styles.lineNumber}><span style={{ padding: "0 6px 0 0" }}>{index}</span>
<Sort {...provided.dragHandleProps} style={{ padding: "5px 0 0 0" }} theme="outline" size="22"
strokeWidth={3} />
</div>
</div>
<div className={styles.sentenceEditorContent}>
<div className={styles.lineNumber}><span style={{ padding: "0 6px 0 0" }}>{index}</span>
<Sort {...provided.dragHandleProps} style={{ padding: "5px 0 0 0" }} theme="outline" size="22"
strokeWidth={3} />
</div>
<div className={styles.seArea}>
<div className={styles.head}>
<div className={styles.title}>
{sentenceConfig.title()}
</div>
<div className={styles.seArea}>
<div className={styles.head}>
<div className={styles.title}>
{sentenceConfig.title()}
</div>
<div className={styles.optionButton}
onClick={() => props.onToggleShow(i)}>
{sentenceItem.show ?
<DownOne strokeWidth={3} theme="outline" size="18"
fill="#005CAF" /> :
<RightOne strokeWidth={3} theme="outline" size="18"
fill="#005CAF" />}
<div className={styles.optionButton}
onClick={() => props.onToggleShow(i)}>
{sentenceItem.show ?
<DownOne strokeWidth={3} theme="outline" size="18"
fill="#005CAF" /> :
<RightOne strokeWidth={3} theme="outline" size="18"
fill="#005CAF" />}
</div>
<div className={styles.optionButtonContainer}>
<div className={styles.optionButton}
onClick={() => props.onDelete(i)}>
<DeleteFive strokeWidth={3} style={{ padding: "2px 4px 0 0" }} theme="outline" size="14"
fill="var(--text)" />
<div>
{t`删除本句`}
</div>
<div className={styles.optionButtonContainer}>
<div className={styles.optionButton}
onClick={() => props.onDelete(i)}>
<DeleteFive strokeWidth={3} style={{ padding: "2px 4px 0 0" }} theme="outline" size="14"
fill="var(--text)" />
<div>
{t`删除本句`}
</div>
</div>
<div className={styles.optionButton}
onClick={() => props.onSync(i)}>
<Play strokeWidth={3} style={{ padding: "2px 4px 0 0" }} theme="outline" size="14"
fill="var(--text)" />
<div>
{t`执行到此句`}
</div>
</div>
</div>
<div className={styles.optionButton}
onClick={() => props.onSync(i)}>
<Play strokeWidth={3} style={{ padding: "2px 4px 0 0" }} theme="outline" size="14"
fill="var(--text)" />
<div>
{t`执行到此句`}
</div>
</div>
{sentenceItem.show && <div className={styles.sentenceEditBody}>
<SentenceEditor sentence={sentence} index={index} onSubmit={(newSentence) => {
props.onUpdate(newSentence, i);
}} targetPath={targetPath} sceneLabels={sceneLabels} extraOptions={inlineArgOption ? argOption : undefined} />
{!inlineArgOption && argOption}
</div>}
</div>
</div>
{sentenceItem.show && <div className={styles.sentenceEditBody}>
<SentenceEditor sentence={sentence} index={index} onSubmit={(newSentence) => {
props.onUpdate(newSentence, i);
}} targetPath={targetPath} sceneLabels={sceneLabels} extraOptions={inlineArgOption ? argOption : undefined} />
{!inlineArgOption && argOption}
</div>}
</div>
)}
</div>
</div>;
};

const SentenceRow = memo((props: SentenceRowProps) => {
return <Draggable key={props.sentenceItem.id} draggableId={props.sentenceItem.id} index={props.index}>
{(provided) => <SentenceRowContent provided={provided} {...props} />}
</Draggable>;
Comment on lines +482 to 485

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass snapshot.isDragging from Draggable to SentenceRowContent as isDraggingPlaceholder so that the original item in the list can hide its content while being dragged.

Suggested change
const SentenceRow = memo((props: SentenceRowProps) => {
return <Draggable key={props.sentenceItem.id} draggableId={props.sentenceItem.id} index={props.index}>
{(provided) => <SentenceRowContent provided={provided} {...props} />}
</Draggable>;
const SentenceRow = memo((props: SentenceRowProps) => {
return <Draggable key={props.sentenceItem.id} draggableId={props.sentenceItem.id} index={props.index}>
{(provided, snapshot) => <SentenceRowContent provided={provided} isDraggingPlaceholder={snapshot.isDragging} {...props} />}
</Draggable>;

}, (prev, next) => (
prev.sentence === next.sentence &&
Expand Down
Loading
Loading