fix(POSTV1-08): 修复文字拖动闪烁与自动保存
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run

This commit is contained in:
suyx
2026-08-05 18:47:11 +08:00
parent 468bb5579d
commit e4aec01ea6
4 changed files with 244 additions and 36 deletions
+85 -11
View File
@@ -86,6 +86,14 @@ interface EditorExportResult {
status: ExportFlowStatus;
}
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
if (!textEdit) return canvasState;
return {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
};
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
@@ -155,6 +163,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
@@ -275,6 +284,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
});
}, [canvasState, selectedIds.join("|")]);
useEffect(() => {
if (textEdit) commitTextDraftAutomatically(textEdit);
}, [textEdit?.draft]);
async function ensureFont(fontId: string, url: string, retry = false) {
const current = fontStatuses[fontId];
if (current === "ready" || (current === "unavailable" && !retry)) return current;
@@ -291,15 +304,24 @@ export function EditorPage({ projectId }: { projectId: string }) {
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
}
function commitCanvas(next: CanvasState) {
function finalizeTextHistory() {
const pending = textHistoryRef.current;
if (!pending) return undefined;
textHistoryRef.current = undefined;
historyRef.current?.commit(pending.last);
return pending.last;
}
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next);
const finalizedText = finalizeTextHistory();
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
if (!options.preserveTextEdit) setTextEdit(undefined);
}
function applyPreview() {
@@ -309,6 +331,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function undo() {
finalizeTextHistory();
const previous = historyRef.current?.undo();
if (previous) {
elementControllerRef.current?.replaceState(previous);
@@ -321,6 +344,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function redo() {
finalizeTextHistory();
const next = historyRef.current?.redo();
if (next) {
elementControllerRef.current?.replaceState(next);
@@ -523,6 +547,37 @@ export function EditorPage({ projectId }: { projectId: string }) {
});
}
function commitTextDraftAutomatically(editState: TextEditState) {
if (!canvasState || !project || saveStatus === "conflicted") return;
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
try {
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
const history = textHistoryRef.current;
if (!history || history.elementId !== editState.elementId) {
if (history) finalizeTextHistory();
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
} else {
history.last = next;
}
elementControllerRef.current?.replaceState(next);
setCanvasState(next);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (complete.template_or_asset_id !== editState.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
setTextEdit((current) => current?.elementId === editState.elementId ? {
...current,
draft: complete,
originalTemplateId: complete.template_or_asset_id,
} : current);
} catch (error) {
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
}
}
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
@@ -549,6 +604,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
function completeTextEdit() {
if (!textEdit) return;
if (!pendingTextDraft()) {
finalizeTextHistory();
showNotice("文字修改已进入自动保存");
return;
}
try {
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
const complete = edit.complete();
@@ -566,8 +626,16 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function cancelTextEdit() {
const pendingHistory = textHistoryRef.current;
if (pendingHistory && project) {
textHistoryRef.current = undefined;
elementControllerRef.current?.replaceState(pendingHistory.base);
setCanvasState(pendingHistory.base);
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
}
if (canvasState && textEdit) {
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
const source = pendingHistory?.base ?? canvasState;
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
}
showNotice("已取消未提交的文字修改");
@@ -717,13 +785,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function selectAt(point: CanvasPoint, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
setSelectedIds(selection);
setCandidateMenu(undefined);
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
const dragBase = withTextDraft(canvasState, textEdit);
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
return candidates.length > 0;
}
@@ -735,19 +805,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
const preview = previewController.moveSelected(delta);
drag.last = preview.state;
setCanvasState(preview.state);
setTextEdit((current) => {
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
return movedDraft ? { ...current, draft: movedDraft } : current;
});
setGuides(preview.guides);
}
function commitMove() {
const drag = dragRef.current;
if (!drag) return;
commitCanvas(drag.last);
commitCanvas(drag.last, { preserveTextEdit: true });
showNotice("对象位置已提交");
setGuides([]);
dragRef.current = undefined;
}
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
@@ -796,6 +872,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function clearSelection() {
finalizeTextHistory();
elementControllerRef.current?.clearSelection();
setSelectedIds([]);
setCandidateMenu(undefined);
@@ -803,10 +880,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = textEdit ? {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
} : canvasState;
const renderedCanvasState = withTextDraft(canvasState, textEdit);
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
@@ -883,7 +957,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
}}>
<EditorStage
assetId={canvasState.background.asset_id}
canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides}
@@ -891,6 +964,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
onClearSelection={clearSelection}
onCopy={copySelection}
onDelete={deleteSelection}
onDragStart={() => setCandidateMenu(undefined)}
onMarquee={marqueeSelect}
onMoveCommit={commitMove}
onMovePreview={previewMove}
+57 -14
View File
@@ -24,7 +24,6 @@ interface Gesture {
const DRAG_THRESHOLD_PX = 4;
interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState;
guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
@@ -32,6 +31,7 @@ interface EditorStageProps {
onClearSelection: () => void;
onCopy: () => void;
onDelete: () => void;
onDragStart: () => void;
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
onMoveCommit: () => void;
onMovePreview: (delta: CanvasPoint) => void;
@@ -244,6 +244,27 @@ function loadCanvasImage(url: string) {
});
}
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
interface SceneResources {
background: HTMLImageElement | undefined;
resourceImages: Readonly<Record<string, HTMLImageElement>>;
}
function createCachedCanvasImageLoader(): CanvasImageLoader {
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
return (url) => {
const cached = cache.get(url);
if (cached) return cached;
const pending = loadCanvasImage(url).then((image) => {
if (!image) cache.delete(url);
return image;
});
cache.set(url, pending);
return pending;
};
}
function resourceUrlsForCanvas(canvasState: CanvasState) {
const imageReferences = new Map<string, string>();
for (const element of canvasState.elements) {
@@ -256,11 +277,19 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
return imageReferences;
}
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
const background = canvasState.background.asset_id
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
: null;
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
return JSON.stringify({ background, projectId, resources });
}
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
const background = canvasState.background.asset_id
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
: Promise.resolve(undefined);
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
const [image, loaded] = await Promise.all([background, resources]);
return {
background: image,
@@ -318,16 +347,29 @@ export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined);
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
const [marquee, setMarquee] = useState<CanvasRect>();
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
useEffect(() => {
let active = true;
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
if (active) setSceneResources({ key: resourceKey, resources });
});
return () => { active = false; };
}, [props.projectId, resourceKey]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height;
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
context.lineWidth = 4;
@@ -349,12 +391,9 @@ export function EditorStage(props: EditorStageProps) {
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore();
};
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
if (!active) return;
render(background, resourceImages);
});
return () => { active = false; };
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
return undefined;
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
@@ -384,8 +423,12 @@ export function EditorStage(props: EditorStageProps) {
return;
}
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
if (!gesture.moved && clientDistance < DRAG_THRESHOLD_PX) return;
gesture.moved = true;
if (!gesture.moved) {
if (clientDistance < DRAG_THRESHOLD_PX) return;
gesture.moved = true;
gesture.longPressOpened = false;
props.onDragStart();
}
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (longPressRef.current) clearTimeout(longPressRef.current);