fix(POSTV1-08): 修复文字拖动闪烁与自动保存
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
@@ -86,6 +86,14 @@ interface EditorExportResult {
|
|||||||
status: ExportFlowStatus;
|
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 {
|
interface EditorProject {
|
||||||
canvas_state?: CanvasState;
|
canvas_state?: CanvasState;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -155,6 +163,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | 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 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 clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||||
@@ -275,6 +284,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
}, [canvasState, selectedIds.join("|")]);
|
}, [canvasState, selectedIds.join("|")]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textEdit) commitTextDraftAutomatically(textEdit);
|
||||||
|
}, [textEdit?.draft]);
|
||||||
|
|
||||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||||
const current = fontStatuses[fontId];
|
const current = fontStatuses[fontId];
|
||||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
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)));
|
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;
|
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);
|
elementControllerRef.current?.replaceState(next);
|
||||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
setCanvasState(next);
|
setCanvasState(next);
|
||||||
setDraftAdjustments(next.background.adjustments);
|
setDraftAdjustments(next.background.adjustments);
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
setTextEdit(undefined);
|
if (!options.preserveTextEdit) setTextEdit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview() {
|
function applyPreview() {
|
||||||
@@ -309,6 +331,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
|
finalizeTextHistory();
|
||||||
const previous = historyRef.current?.undo();
|
const previous = historyRef.current?.undo();
|
||||||
if (previous) {
|
if (previous) {
|
||||||
elementControllerRef.current?.replaceState(previous);
|
elementControllerRef.current?.replaceState(previous);
|
||||||
@@ -321,6 +344,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
|
finalizeTextHistory();
|
||||||
const next = historyRef.current?.redo();
|
const next = historyRef.current?.redo();
|
||||||
if (next) {
|
if (next) {
|
||||||
elementControllerRef.current?.replaceState(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) {
|
async function changeTextTemplate(templateId: string) {
|
||||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||||
if (!template?.fontUrl) return;
|
if (!template?.fontUrl) return;
|
||||||
@@ -549,6 +604,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
function completeTextEdit() {
|
function completeTextEdit() {
|
||||||
if (!textEdit) return;
|
if (!textEdit) return;
|
||||||
|
if (!pendingTextDraft()) {
|
||||||
|
finalizeTextHistory();
|
||||||
|
showNotice("文字修改已进入自动保存");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||||
const complete = edit.complete();
|
const complete = edit.complete();
|
||||||
@@ -566,8 +626,16 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelTextEdit() {
|
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) {
|
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 });
|
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||||
}
|
}
|
||||||
showNotice("已取消未提交的文字修改");
|
showNotice("已取消未提交的文字修改");
|
||||||
@@ -717,13 +785,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectAt(point: CanvasPoint, append: boolean) {
|
function selectAt(point: CanvasPoint, append: boolean) {
|
||||||
|
finalizeTextHistory();
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || !canvasState) return false;
|
if (!controller || !canvasState) return false;
|
||||||
const candidates = controller.candidatesAt(point);
|
const candidates = controller.candidatesAt(point);
|
||||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||||
setSelectedIds(selection);
|
setSelectedIds(selection);
|
||||||
setCandidateMenu(undefined);
|
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;
|
return candidates.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,19 +805,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const preview = previewController.moveSelected(delta);
|
const preview = previewController.moveSelected(delta);
|
||||||
drag.last = preview.state;
|
drag.last = preview.state;
|
||||||
setCanvasState(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);
|
setGuides(preview.guides);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitMove() {
|
function commitMove() {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
commitCanvas(drag.last);
|
commitCanvas(drag.last, { preserveTextEdit: true });
|
||||||
showNotice("对象位置已提交");
|
showNotice("对象位置已提交");
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
dragRef.current = undefined;
|
dragRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||||
|
finalizeTextHistory();
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||||
@@ -796,6 +872,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
|
finalizeTextHistory();
|
||||||
elementControllerRef.current?.clearSelection();
|
elementControllerRef.current?.clearSelection();
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
@@ -803,10 +880,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||||
const renderedCanvasState = textEdit ? {
|
const renderedCanvasState = withTextDraft(canvasState, textEdit);
|
||||||
...canvasState,
|
|
||||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
|
||||||
} : canvasState;
|
|
||||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||||
const canEdit = saveStatus !== "conflicted";
|
const canEdit = saveStatus !== "conflicted";
|
||||||
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
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))`,
|
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
|
<EditorStage
|
||||||
assetId={canvasState.background.asset_id}
|
|
||||||
canvasState={renderedCanvasState}
|
canvasState={renderedCanvasState}
|
||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
guides={guides}
|
guides={guides}
|
||||||
@@ -891,6 +964,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onCopy={copySelection}
|
onCopy={copySelection}
|
||||||
onDelete={deleteSelection}
|
onDelete={deleteSelection}
|
||||||
|
onDragStart={() => setCandidateMenu(undefined)}
|
||||||
onMarquee={marqueeSelect}
|
onMarquee={marqueeSelect}
|
||||||
onMoveCommit={commitMove}
|
onMoveCommit={commitMove}
|
||||||
onMovePreview={previewMove}
|
onMovePreview={previewMove}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface Gesture {
|
|||||||
const DRAG_THRESHOLD_PX = 4;
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
interface EditorStageProps {
|
interface EditorStageProps {
|
||||||
assetId: string | null;
|
|
||||||
canvasState: CanvasState;
|
canvasState: CanvasState;
|
||||||
guides: readonly string[];
|
guides: readonly string[];
|
||||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
@@ -32,6 +31,7 @@ interface EditorStageProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||||
onMoveCommit: () => void;
|
onMoveCommit: () => void;
|
||||||
onMovePreview: (delta: CanvasPoint) => 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) {
|
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||||
const imageReferences = new Map<string, string>();
|
const imageReferences = new Map<string, string>();
|
||||||
for (const element of canvasState.elements) {
|
for (const element of canvasState.elements) {
|
||||||
@@ -256,11 +277,19 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
|
|||||||
return imageReferences;
|
return imageReferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
|
||||||
const background = canvasState.background.asset_id
|
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);
|
: 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]);
|
const [image, loaded] = await Promise.all([background, resources]);
|
||||||
return {
|
return {
|
||||||
background: image,
|
background: image,
|
||||||
@@ -318,16 +347,29 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | 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 [marquee, setMarquee] = useState<CanvasRect>();
|
||||||
|
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
|
||||||
|
|
||||||
|
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
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;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return undefined;
|
if (!canvas) return undefined;
|
||||||
canvas.width = props.canvasState.pixel_width;
|
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
|
||||||
canvas.height = props.canvasState.pixel_height;
|
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return undefined;
|
if (!context) return undefined;
|
||||||
|
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
|
||||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||||
context.lineWidth = 4;
|
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);
|
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
|
||||||
if (!active) return;
|
return undefined;
|
||||||
render(background, resourceImages);
|
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
|
||||||
});
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
|
||||||
|
|
||||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||||
|
|
||||||
@@ -384,8 +423,12 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||||
if (!gesture.moved && clientDistance < DRAG_THRESHOLD_PX) return;
|
if (!gesture.moved) {
|
||||||
|
if (clientDistance < DRAG_THRESHOLD_PX) return;
|
||||||
gesture.moved = true;
|
gesture.moved = true;
|
||||||
|
gesture.longPressOpened = false;
|
||||||
|
props.onDragStart();
|
||||||
|
}
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { CanvasState } from "@dada/shared-contracts";
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
|
||||||
|
|
||||||
let vite: ViteDevServer;
|
let vite: ViteDevServer;
|
||||||
let webUrl: string;
|
let webUrl: string;
|
||||||
|
|
||||||
@@ -196,3 +198,89 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
|
|||||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const counters = { height: 0, width: 0 };
|
||||||
|
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
|
||||||
|
for (const key of ["height", "width"] as const) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
|
||||||
|
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, key, {
|
||||||
|
configurable: descriptor.configurable,
|
||||||
|
enumerable: descriptor.enumerable,
|
||||||
|
get: descriptor.get,
|
||||||
|
set(value: number) {
|
||||||
|
if (this.classList.contains("editor-canvas")) counters[key] += 1;
|
||||||
|
descriptor.set!.call(this, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = uuid(530);
|
||||||
|
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
|
||||||
|
createdAt: "2026-08-03T08:00:00.000Z",
|
||||||
|
elementId: uuid(630),
|
||||||
|
}, 0, { position: { x: 0.5, y: 0.5 } });
|
||||||
|
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
const stage = page.getByLabel("编辑画布");
|
||||||
|
const bounds = await stage.boundingBox();
|
||||||
|
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
||||||
|
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
|
||||||
|
await page.mouse.click(center.x, center.y);
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
await page.getByLabel("文字内容").fill("拖动中的文字");
|
||||||
|
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
|
||||||
|
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
|
||||||
|
expect(backend.canvas.elements[0]).toMatchObject({
|
||||||
|
content: "拖动中的文字",
|
||||||
|
scale: { x: 64 / 48, y: 64 / 48 },
|
||||||
|
style_parameters: { fill_color: "#FA5751" },
|
||||||
|
});
|
||||||
|
const savesBeforeDrag = backend.saves;
|
||||||
|
|
||||||
|
const before = await page.evaluate(() => {
|
||||||
|
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
||||||
|
});
|
||||||
|
await page.mouse.move(center.x, center.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(650);
|
||||||
|
await expect(page.getByRole("menu")).toBeVisible();
|
||||||
|
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
|
||||||
|
await expect(page.getByRole("menu")).toBeHidden();
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
|
||||||
|
const preview = await stage.evaluate((canvas: HTMLCanvasElement) => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas context unavailable.");
|
||||||
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let count = 0;
|
||||||
|
let totalX = 0;
|
||||||
|
for (let y = 0; y < canvas.height; y += 1) {
|
||||||
|
for (let x = 0; x < canvas.width; x += 1) {
|
||||||
|
const offset = (y * canvas.width + x) * 4;
|
||||||
|
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
|
||||||
|
count += 1;
|
||||||
|
totalX += x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
|
||||||
|
});
|
||||||
|
const during = await page.evaluate(() => {
|
||||||
|
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(during).toEqual(before);
|
||||||
|
expect(preview.blue_pixel_count).toBeGreaterThan(100);
|
||||||
|
expect(preview.blue_x).toBeGreaterThan(0.60);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
|
||||||
|
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
|
||||||
|
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
|
|||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000920";
|
const projectId = "00000000-0000-4000-8000-000000000920";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000921";
|
const assetId = "00000000-0000-4000-8000-000000000921";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
||||||
@@ -87,27 +87,29 @@ test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and expor
|
|||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
await page.getByLabel("文字内容").fill("自动保存的导出文字");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
||||||
const downloads: string[] = [];
|
const downloads: string[] = [];
|
||||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
await expect(dialog).toContainText("导出前需要提交当前修改");
|
await expect(dialog).not.toContainText("导出前需要提交当前修改");
|
||||||
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
||||||
await dialog.getByRole("button", { name: "取消" }).click();
|
await dialog.getByRole("button", { name: "取消" }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
|
||||||
expect(backend.saves).toBe(1);
|
expect(backend.saves).toBe(2);
|
||||||
|
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
|
||||||
expect(backend.latestBodies).toHaveLength(0);
|
expect(backend.latestBodies).toHaveLength(0);
|
||||||
expect(downloads).toHaveLength(0);
|
expect(downloads).toHaveLength(0);
|
||||||
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000930";
|
const projectId = "00000000-0000-4000-8000-000000000930";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000931";
|
const assetId = "00000000-0000-4000-8000-000000000931";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
||||||
@@ -117,6 +119,7 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
|
|||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("确认后进入导出");
|
await page.getByLabel("文字内容").fill("确认后进入导出");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
||||||
@@ -124,14 +127,14 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
|
|||||||
mkdirSync(dirname(screenshot), { recursive: true });
|
mkdirSync(dirname(screenshot), { recursive: true });
|
||||||
await page.screenshot({ fullPage: true, path: screenshot });
|
await page.screenshot({ fullPage: true, path: screenshot });
|
||||||
}
|
}
|
||||||
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
||||||
const download = await downloadPromise;
|
const download = await downloadPromise;
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
||||||
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
||||||
await expect.poll(() => backend.saves).toBe(2);
|
expect(backend.saves).toBe(2);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
||||||
expect(backend.latestBodies).toHaveLength(1);
|
expect(backend.latestBodies).toHaveLength(1);
|
||||||
const downloaded = readFileSync(downloadPath);
|
const downloaded = readFileSync(downloadPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user