Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3b2c6914 | ||
|
|
42f993378f | ||
|
|
e9fd15e7b6 |
+2
-1
@@ -3,7 +3,8 @@
|
||||
"browsers": [
|
||||
{
|
||||
"brand": "Google Chrome",
|
||||
"fullVersion": "150.0.7871.187"
|
||||
"fullVersion": "150.0.7871.187",
|
||||
"supportedMajorVersions": [150, 151]
|
||||
},
|
||||
{
|
||||
"brand": "Microsoft Edge",
|
||||
|
||||
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
|
||||
const system: AdminDiagnosticsResponse["system"] = {
|
||||
api_status: "ready",
|
||||
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
||||
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
|
||||
brand: browser.brand,
|
||||
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
|
||||
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
|
||||
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
|
||||
const majors = browser.supportedMajorVersions
|
||||
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
|
||||
return majors
|
||||
.map((major) => ({ brand: browser.brand, major }))
|
||||
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
|
||||
}),
|
||||
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
||||
? "ready"
|
||||
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
||||
|
||||
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
|
||||
app_version: Type.String({ maxLength: 80 }),
|
||||
browser: SupportedBrowserSummarySchema,
|
||||
status: Type.Literal("supported"),
|
||||
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
|
||||
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
||||
);
|
||||
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
|
||||
browsers: ReadonlyArray<{
|
||||
brand: SupportedBrand;
|
||||
fullVersion: string;
|
||||
supportedMajorVersions?: ReadonlyArray<number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
|
||||
|
||||
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
||||
if (!release) return [];
|
||||
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
|
||||
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||
const majors = supportedMajorVersions ?? [major(fullVersion)!];
|
||||
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
|
||||
});
|
||||
}
|
||||
|
||||
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
|
||||
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
|
||||
}
|
||||
|
||||
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
||||
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
|
||||
}
|
||||
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
||||
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
||||
const supportedMajorCount = release.browsers.reduce(
|
||||
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
brands.size === 2 &&
|
||||
brands.has("Google Chrome") &&
|
||||
brands.has("Microsoft Edge") &&
|
||||
release.browsers.every(
|
||||
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
|
||||
)
|
||||
supportedMajorCount <= 8 &&
|
||||
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
|
||||
const baselineMajor = major(fullVersion);
|
||||
if (!baselineMajor) return false;
|
||||
if (supportedMajorVersions === undefined) return true;
|
||||
return supportedMajorVersions.length > 0
|
||||
&& supportedMajorVersions.length <= 8
|
||||
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
|
||||
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
|
||||
&& supportedMajorVersions.includes(baselineMajor);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
|
||||
}
|
||||
|
||||
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
||||
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
|
||||
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
|
||||
return { reason: "version_unsupported", supported: false };
|
||||
}
|
||||
return { identity: fullIdentity, supported: true };
|
||||
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
|
||||
return { reason: "identity_unavailable" as const, supported: false as const };
|
||||
}
|
||||
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
||||
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
|
||||
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
|
||||
return { reason: "version_unsupported" as const, supported: false as const };
|
||||
}
|
||||
return { identity: currentIdentity, supported: true as const };
|
||||
|
||||
@@ -2,6 +2,27 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
|
||||
|
||||
interface CanvasSize {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface CanvasRect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface BackgroundDrawPlan {
|
||||
destination: CanvasRect;
|
||||
source: CanvasRect;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
const elementKeys = new Set([
|
||||
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
|
||||
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
|
||||
@@ -106,7 +127,99 @@ export function deserializeFabricCanvas(input: unknown): CanvasState | undefined
|
||||
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
|
||||
}
|
||||
|
||||
export function backgroundDrawPlan(
|
||||
image: CanvasSize,
|
||||
canvas: CanvasSize,
|
||||
adjustments: BackgroundAdjustments,
|
||||
): BackgroundDrawPlan {
|
||||
if (image.width <= 0 || image.height <= 0 || canvas.width <= 0 || canvas.height <= 0) {
|
||||
throw new Error("background_dimensions_invalid");
|
||||
}
|
||||
const crop = adjustments.crop;
|
||||
const normalizedX = crop ? clamp(crop.x, 0, 1) : 0;
|
||||
const normalizedY = crop ? clamp(crop.y, 0, 1) : 0;
|
||||
const normalizedWidth = crop ? Math.min(crop.width, 1 - normalizedX) : 1;
|
||||
const normalizedHeight = crop ? Math.min(crop.height, 1 - normalizedY) : 1;
|
||||
const source = normalizedWidth > 0 && normalizedHeight > 0
|
||||
? {
|
||||
height: image.height * normalizedHeight,
|
||||
width: image.width * normalizedWidth,
|
||||
x: image.width * normalizedX,
|
||||
y: image.height * normalizedY,
|
||||
}
|
||||
: { height: image.height, width: image.width, x: 0, y: 0 };
|
||||
|
||||
if (adjustments.fit === "fill") {
|
||||
return { destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, source };
|
||||
}
|
||||
if (adjustments.fit === "fit") {
|
||||
const scale = Math.min(canvas.width / source.width, canvas.height / source.height);
|
||||
const width = source.width * scale;
|
||||
const height = source.height * scale;
|
||||
return {
|
||||
destination: { height, width, x: (canvas.width - width) / 2, y: (canvas.height - height) / 2 },
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceAspect = source.width / source.height;
|
||||
const canvasAspect = canvas.width / canvas.height;
|
||||
if (sourceAspect > canvasAspect) {
|
||||
const width = source.height * canvasAspect;
|
||||
source.x += (source.width - width) / 2;
|
||||
source.width = width;
|
||||
} else if (sourceAspect < canvasAspect) {
|
||||
const height = source.width / canvasAspect;
|
||||
source.y += (source.height - height) / 2;
|
||||
source.height = height;
|
||||
}
|
||||
return {
|
||||
destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 },
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export function adjustBackgroundPixels(
|
||||
pixels: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
adjustments: Pick<BackgroundAdjustments, "sharpness" | "temperature">,
|
||||
) {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) {
|
||||
throw new Error("background_pixel_buffer_invalid");
|
||||
}
|
||||
const source = new Uint8ClampedArray(pixels);
|
||||
const output = new Uint8ClampedArray(source);
|
||||
const sharpness = clamp(adjustments.sharpness, 0, 100) / 100;
|
||||
const temperature = clamp(adjustments.temperature, -100, 100) / 100;
|
||||
const channelOffsets = [35 * temperature, 8 * temperature, -35 * temperature];
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
let value = source[index + channel]!;
|
||||
if (sharpness > 0 && x > 0 && x < width - 1 && y > 0 && y < height - 1) {
|
||||
const left = source[index + channel - 4]!;
|
||||
const right = source[index + channel + 4]!;
|
||||
const above = source[index + channel - width * 4]!;
|
||||
const below = source[index + channel + width * 4]!;
|
||||
value = value * (1 + 4 * sharpness) - (left + right + above + below) * sharpness;
|
||||
}
|
||||
output[index + channel] = value + channelOffsets[channel]!;
|
||||
}
|
||||
output[index + 3] = source[index + 3]!;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
|
||||
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
|
||||
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
|
||||
const filters: string[] = [];
|
||||
if (adjustments.filter === "grayscale") filters.push("grayscale(1)");
|
||||
else if (adjustments.filter === "sepia") filters.push("sepia(0.75)");
|
||||
if (adjustments.brightness !== 0) filters.push(`brightness(${100 + adjustments.brightness}%)`);
|
||||
if (adjustments.contrast !== 0) filters.push(`contrast(${100 + adjustments.contrast}%)`);
|
||||
if (adjustments.saturation !== 0) filters.push(`saturate(${100 + adjustments.saturation}%)`);
|
||||
return filters.length > 0 ? filters.join(" ") : "none";
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ export class CanvasElementController {
|
||||
.map((element) => structuredClone(element));
|
||||
}
|
||||
|
||||
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
|
||||
selectAt(point: CanvasPoint, options: { append?: boolean; preserveSelection?: boolean } = {}) {
|
||||
const candidates = this.candidatesAt(point);
|
||||
if (candidates.length === 0) {
|
||||
if (!options.append) this.selection = [];
|
||||
@@ -196,7 +196,9 @@ export class CanvasElementController {
|
||||
}
|
||||
if (options.append) {
|
||||
this.pointerMoved();
|
||||
return this.selectById(candidates[0]!.element_id, true);
|
||||
const elementId = candidates[0]!.element_id;
|
||||
if (options.preserveSelection && this.selection.includes(elementId)) return this.selectedIds;
|
||||
return this.selectById(elementId, true);
|
||||
}
|
||||
const signature = candidates.map((candidate) => candidate.element_id).join("|");
|
||||
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
|
||||
@@ -255,7 +257,8 @@ export class CanvasElementController {
|
||||
|
||||
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
|
||||
const selected = new Set(this.selection);
|
||||
const primary = this.current.elements.find((element) => selected.has(element.element_id));
|
||||
const selectedElements = this.current.elements.filter((element) => selected.has(element.element_id));
|
||||
const primary = selectedElements[0];
|
||||
if (!primary) return { guides: [] as string[], state: this.value };
|
||||
let nextX = primary.position.x + delta.x;
|
||||
let nextY = primary.position.y + delta.y;
|
||||
@@ -278,10 +281,20 @@ export class CanvasElementController {
|
||||
nextX = snapAxis(nextX, "x");
|
||||
nextY = snapAxis(nextY, "y");
|
||||
}
|
||||
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
|
||||
const minimumX = Math.min(...selectedElements.map((element) => element.position.x));
|
||||
const maximumX = Math.max(...selectedElements.map((element) => element.position.x));
|
||||
const minimumY = Math.min(...selectedElements.map((element) => element.position.y));
|
||||
const maximumY = Math.max(...selectedElements.map((element) => element.position.y));
|
||||
const adjusted = {
|
||||
x: clamp(nextX - primary.position.x, -minimumX, 1 - maximumX),
|
||||
y: clamp(nextY - primary.position.y, -minimumY, 1 - maximumY),
|
||||
};
|
||||
const state = this.updateSelected((element) => ({
|
||||
...element,
|
||||
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
|
||||
position: {
|
||||
x: clamp(Number((element.position.x + adjusted.x).toFixed(12)), 0, 1),
|
||||
y: clamp(Number((element.position.y + adjusted.y).toFixed(12)), 0, 1),
|
||||
},
|
||||
}));
|
||||
return { guides, state };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
.editor-page-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||
background: #e8e8e5;
|
||||
color: #111111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-page-shell :focus-visible {
|
||||
@@ -127,7 +124,6 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-assets-panel,
|
||||
@@ -546,13 +542,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
|
||||
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||
.editor-history-actions { order: 3; }
|
||||
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||
.editor-assets-panel { order: 2; }
|
||||
.editor-inspector { order: 3; }
|
||||
|
||||
@@ -160,20 +160,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
function showNotice(message: string) {
|
||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||
setNotice(message);
|
||||
noticeTimerRef.current = setTimeout(() => {
|
||||
setNotice("");
|
||||
noticeTimerRef.current = undefined;
|
||||
}, 3_000);
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -189,7 +175,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
elementControllerRef.current = new CanvasElementController(initial);
|
||||
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
@@ -305,7 +291,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function applyPreview() {
|
||||
if (!canvasState) return;
|
||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||
showNotice("底图调整已提交");
|
||||
setNotice("底图调整已提交");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
@@ -348,9 +334,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const palette = await paletteForAsset(pendingBackground);
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||
setPendingBackground(undefined);
|
||||
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
} catch {
|
||||
showNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +353,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitCanvas(next);
|
||||
setGuides([]);
|
||||
setCandidateMenu(undefined);
|
||||
showNotice(message);
|
||||
setNotice(message);
|
||||
}
|
||||
|
||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||
@@ -383,8 +369,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}));
|
||||
commitElementOperation(controller, "贴纸已加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("贴纸未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,8 +383,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +394,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (templateId === "DYN012") {
|
||||
const font = fontOption("FONT081");
|
||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -421,8 +407,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||
setLocationDialog(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("动态贴纸未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("动态贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +456,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||
} catch {
|
||||
showNotice("动态贴纸显示文字不能为空");
|
||||
setNotice("动态贴纸显示文字不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +480,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (!template.fontUrl || !canvasState) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
const controller = controllerForCurrent();
|
||||
@@ -504,8 +490,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitElementOperation(controller, "文字模板已加入画布");
|
||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("文字模板未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("文字模板未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +503,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
action(edit);
|
||||
return { ...current, draft: edit.value };
|
||||
} catch {
|
||||
showNotice("文字参数不在允许范围内");
|
||||
setNotice("文字参数不在允许范围内");
|
||||
return current;
|
||||
}
|
||||
});
|
||||
@@ -528,7 +514,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (!template?.fontUrl) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||
@@ -541,7 +527,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
const option = fontOption(fontId);
|
||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||
showNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||
@@ -560,8 +546,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
|
||||
else showNotice("文字编辑未能完成");
|
||||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||
else setNotice("文字编辑未能完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +556,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const current = canvasState.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("已取消未提交的文字修改");
|
||||
setNotice("已取消未提交的文字修改");
|
||||
}
|
||||
|
||||
function pendingTextDraft() {
|
||||
@@ -638,7 +624,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
return;
|
||||
}
|
||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
|
||||
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||
}
|
||||
|
||||
async function retryExportDownload() {
|
||||
@@ -653,8 +639,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
action(controller);
|
||||
commitElementOperation(controller, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("对象操作未完成");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("对象操作未完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,7 +668,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
opacityGestureRef.current = undefined;
|
||||
if (gesture.last === gesture.base) return;
|
||||
commitCanvas(gesture.last);
|
||||
showNotice("贴纸透明度已提交");
|
||||
setNotice("贴纸透明度已提交");
|
||||
}
|
||||
|
||||
function duplicateSelection() {
|
||||
@@ -702,7 +688,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
clipboardRef.current = controller.copySelected();
|
||||
showNotice("已复制到画布剪贴板");
|
||||
setNotice("已复制到画布剪贴板");
|
||||
}
|
||||
|
||||
function pasteSelection() {
|
||||
@@ -712,7 +698,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||
commitElementOperation(controller, "已粘贴画布对象");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,7 +706,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return false;
|
||||
const candidates = controller.candidatesAt(point);
|
||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||
const selection = controller.selectAt(point, {
|
||||
append: append || multiMode,
|
||||
preserveSelection: multiMode && !append,
|
||||
});
|
||||
setSelectedIds(selection);
|
||||
setCandidateMenu(undefined);
|
||||
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||
@@ -742,7 +731,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
commitCanvas(drag.last);
|
||||
showNotice("对象位置已提交");
|
||||
setNotice("对象位置已提交");
|
||||
setGuides([]);
|
||||
dragRef.current = undefined;
|
||||
}
|
||||
@@ -803,10 +792,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
|
||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||
const renderedCanvasState = textEdit ? {
|
||||
const backgroundPreviewState: CanvasState = {
|
||||
...canvasState,
|
||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||
} : canvasState;
|
||||
background: { ...canvasState.background, adjustments: draftAdjustments },
|
||||
};
|
||||
const renderedCanvasState = textEdit ? {
|
||||
...backgroundPreviewState,
|
||||
elements: backgroundPreviewState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||
} : backgroundPreviewState;
|
||||
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));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||
import { adjustBackgroundPixels, backgroundDrawPlan, cssFilterForBackground } from "./editor-canvas.js";
|
||||
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
||||
@@ -12,17 +12,12 @@ import { drawColorCard } from "./palette-provider.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
bounds: DOMRect;
|
||||
hit: boolean;
|
||||
longPressOpened: boolean;
|
||||
moved: boolean;
|
||||
pointerId: number;
|
||||
start: CanvasPoint;
|
||||
startClient: CanvasPoint;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
interface EditorStageProps {
|
||||
assetId: string | null;
|
||||
canvasState: CanvasState;
|
||||
@@ -43,10 +38,11 @@ interface EditorStageProps {
|
||||
selectedIds: readonly string[];
|
||||
}
|
||||
|
||||
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
|
||||
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
|
||||
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,9 +274,41 @@ function renderEditorScene(
|
||||
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = "none";
|
||||
if (background) {
|
||||
const plan = backgroundDrawPlan(
|
||||
{ height: background.naturalHeight || background.height, width: background.naturalWidth || background.width },
|
||||
{ height: canvasState.pixel_height, width: canvasState.pixel_width },
|
||||
canvasState.background.adjustments,
|
||||
);
|
||||
context.save();
|
||||
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||
context.drawImage(
|
||||
background,
|
||||
plan.source.x,
|
||||
plan.source.y,
|
||||
plan.source.width,
|
||||
plan.source.height,
|
||||
plan.destination.x,
|
||||
plan.destination.y,
|
||||
plan.destination.width,
|
||||
plan.destination.height,
|
||||
);
|
||||
context.restore();
|
||||
|
||||
if (canvasState.background.adjustments.temperature !== 0 || canvasState.background.adjustments.sharpness !== 0) {
|
||||
const x = Math.max(0, Math.floor(plan.destination.x));
|
||||
const y = Math.max(0, Math.floor(plan.destination.y));
|
||||
const right = Math.min(canvasState.pixel_width, Math.ceil(plan.destination.x + plan.destination.width));
|
||||
const bottom = Math.min(canvasState.pixel_height, Math.ceil(plan.destination.y + plan.destination.height));
|
||||
const width = right - x;
|
||||
const height = bottom - y;
|
||||
if (width > 0 && height > 0) {
|
||||
const imageData = context.getImageData(x, y, width, height);
|
||||
imageData.data.set(adjustBackgroundPixels(imageData.data, width, height, canvasState.background.adjustments));
|
||||
context.putImageData(imageData, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
|
||||
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
|
||||
}
|
||||
@@ -360,14 +388,10 @@ export function EditorStage(props: EditorStageProps) {
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||
if (event.button !== 0) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const start = pointFromClient(event.clientX, event.clientY, bounds);
|
||||
const start = pointFromEvent(event);
|
||||
const append = event.shiftKey;
|
||||
const hit = props.onSelect(start, append);
|
||||
gestureRef.current = {
|
||||
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
|
||||
startClient: { x: event.clientX, y: event.clientY },
|
||||
};
|
||||
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
longPressRef.current = setTimeout(() => {
|
||||
const gesture = gestureRef.current;
|
||||
@@ -383,11 +407,9 @@ export function EditorStage(props: EditorStageProps) {
|
||||
props.onPointerMoved();
|
||||
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;
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
const point = pointFromEvent(event);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
props.onPointerMoved();
|
||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||
@@ -398,18 +420,11 @@ export function EditorStage(props: EditorStageProps) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
|
||||
if (moved && !gesture.moved && !gesture.longPressOpened) {
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
if (gesture.hit) props.onMovePreview(delta);
|
||||
}
|
||||
const point = pointFromEvent(event);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||
else if (!gesture.hit && moved) {
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||
}
|
||||
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||
setMarquee(undefined);
|
||||
gestureRef.current = undefined;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
@@ -107,5 +107,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||
} catch {
|
||||
storageStatus = "unavailable";
|
||||
control.reportStatus("storage_unavailable");
|
||||
clearInterval(keepAlive);
|
||||
setTimeout(() => process.exit(1), 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
|
||||
browser_support: Type.Array(Type.Object({
|
||||
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
||||
major: Type.Integer({ minimum: 1 }),
|
||||
}, { additionalProperties: false }), { maxItems: 2 }),
|
||||
}, { additionalProperties: false }), { maxItems: 8 }),
|
||||
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
||||
}, { additionalProperties: false }),
|
||||
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
||||
|
||||
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
{ maxItems: 2 },
|
||||
{ maxItems: 8 },
|
||||
),
|
||||
),
|
||||
capacity_status: Type.Optional(
|
||||
|
||||
@@ -14,7 +14,11 @@ export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-0
|
||||
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
||||
const record = {
|
||||
appVersion,
|
||||
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
|
||||
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
|
||||
brand,
|
||||
fullVersion,
|
||||
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
|
||||
})),
|
||||
buildCommit: buildCommit.toLowerCase(),
|
||||
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
||||
finalRelease: true,
|
||||
@@ -44,8 +48,25 @@ export function validateFinalReleaseRecord(record) {
|
||||
} else {
|
||||
const brands = record.browsers.map(({ brand }) => brand).sort();
|
||||
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
||||
const supportedMajorCount = record.browsers.reduce(
|
||||
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
|
||||
? browser.supportedMajorVersions.length
|
||||
: 1),
|
||||
0,
|
||||
);
|
||||
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
|
||||
for (const browser of record.browsers) {
|
||||
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
||||
if (browser.supportedMajorVersions !== undefined) {
|
||||
const values = browser.supportedMajorVersions;
|
||||
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
|
||||
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|
||||
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|
||||
|| new Set(values).size !== values.length
|
||||
|| !values.includes(baselineMajor)) {
|
||||
errors.push(`${browser.brand}.supportedMajorVersions`);
|
||||
}
|
||||
}
|
||||
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ internal static class Program
|
||||
};
|
||||
var state = await runtime.StartAsync();
|
||||
if (!form.IsDisposed) form.SetState(state);
|
||||
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
|
||||
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
|
||||
{
|
||||
form.SetBrowserLaunchFailure();
|
||||
}
|
||||
}
|
||||
form.Shown += async (_, _) => await StartRuntimeAsync();
|
||||
form.RestartRequested += async () => await StartRuntimeAsync();
|
||||
|
||||
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
|
||||
Font = new Font("Segoe UI", 9F);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = true;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Text = "Dada";
|
||||
|
||||
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
|
||||
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
||||
|
||||
FormClosing += (_, _) => trayIcon.Visible = false;
|
||||
Resize += (_, _) =>
|
||||
{
|
||||
if (WindowState == FormWindowState.Minimized) Hide();
|
||||
};
|
||||
SetState(initialState);
|
||||
}
|
||||
|
||||
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
|
||||
Activate();
|
||||
}
|
||||
|
||||
internal void SetBrowserLaunchFailure()
|
||||
{
|
||||
if (state == SupervisorState.Ready)
|
||||
{
|
||||
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) trayIcon.Dispose();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
|
||||
{
|
||||
var executable = FindExecutable(executableName);
|
||||
if (executable is null) return false;
|
||||
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
||||
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
||||
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
||||
return Process.Start(startInfo) is not null;
|
||||
}
|
||||
catch (Win32Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindExecutable(string executableName)
|
||||
|
||||
@@ -13,6 +13,14 @@ const supportedEdge = browserSupportFixture({
|
||||
brand: "Microsoft Edge",
|
||||
fullVersion: "150.0.4078.99",
|
||||
});
|
||||
const supportedChrome150 = browserSupportFixture({
|
||||
brand: "Google Chrome",
|
||||
fullVersion: "150.0.7871.187",
|
||||
});
|
||||
const supportedChrome151 = browserSupportFixture({
|
||||
brand: "Google Chrome",
|
||||
fullVersion: "151.0.0.0",
|
||||
});
|
||||
const rejectedIdentityCases = [
|
||||
{
|
||||
expectedReason: "platform_unsupported",
|
||||
@@ -145,6 +153,7 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
||||
status: "supported",
|
||||
supported_browsers: [
|
||||
{ brand: "Google Chrome", major: 150 },
|
||||
{ brand: "Google Chrome", major: 151 },
|
||||
{ brand: "Microsoft Edge", major: 150 },
|
||||
],
|
||||
});
|
||||
@@ -188,6 +197,26 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
||||
expect(staleCookie.statusCode).toBe(426);
|
||||
await restarted.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expectedMajor: 150, fixture: supportedChrome150 },
|
||||
{ expectedMajor: 151, fixture: supportedChrome151 },
|
||||
])("accepts explicitly declared Chrome $expectedMajor", async ({ expectedMajor, fixture }) => {
|
||||
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
|
||||
const checked = await app.inject({
|
||||
headers: fixture.headers,
|
||||
method: "POST",
|
||||
payload: fixture.body,
|
||||
url: "/api/v1/support/check",
|
||||
});
|
||||
|
||||
expect(checked.statusCode).toBe(200);
|
||||
expect(checked.json()).toMatchObject({
|
||||
browser: { brand: "Google Chrome", major: expectedMajor },
|
||||
status: "supported",
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP0-BRW-002 hard block", () => {
|
||||
@@ -208,6 +237,7 @@ describe("TDD-WP0-BRW-002 hard block", () => {
|
||||
reason: expectedReason,
|
||||
supported_browsers: [
|
||||
{ brand: "Google Chrome", major: 150 },
|
||||
{ brand: "Google Chrome", major: 151 },
|
||||
{ brand: "Microsoft Edge", major: 150 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -67,6 +67,20 @@ async function routeEditor(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function canvasFingerprint(page: Page) {
|
||||
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas context unavailable.");
|
||||
return [
|
||||
...context.getImageData(108, 720, 1, 1).data,
|
||||
...context.getImageData(324, 720, 1, 1).data,
|
||||
...context.getImageData(540, 720, 1, 1).data,
|
||||
...context.getImageData(756, 720, 1, 1).data,
|
||||
...context.getImageData(972, 720, 1, 1).data,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
|
||||
await routeEditor(page);
|
||||
const saves: Array<Record<string, unknown>> = [];
|
||||
@@ -144,3 +158,35 @@ test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing"
|
||||
writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true });
|
||||
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined });
|
||||
});
|
||||
|
||||
test("TDD-WP4-BG-002 previews background pixels before committing", async ({ page }) => {
|
||||
await routeEditor(page);
|
||||
const saves: Array<Record<string, unknown>> = [];
|
||||
let version = 7;
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
saves.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "恢复原图" }).click();
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
|
||||
await expect.poll(async () => (await canvasFingerprint(page)).some((channel) => channel < 240)).toBe(true);
|
||||
const baseline = (await canvasFingerprint(page)).join(",");
|
||||
|
||||
const ranges = page.locator(".editor-inspector input[type=range]");
|
||||
await ranges.nth(0).fill("40");
|
||||
await ranges.nth(1).fill("25");
|
||||
await ranges.nth(2).fill("-35");
|
||||
await ranges.nth(3).fill("70");
|
||||
await ranges.nth(4).fill("100");
|
||||
await expect.poll(async () => (await canvasFingerprint(page)).join(",")).not.toBe(baseline);
|
||||
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1);
|
||||
const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState };
|
||||
expect(committed.canvas_state.background.adjustments).toMatchObject({
|
||||
brightness: 40, contrast: 25, saturation: -35, sharpness: 100, temperature: 70,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ const session = {
|
||||
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
|
||||
};
|
||||
|
||||
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材");
|
||||
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT
|
||||
?? join(homedir(), "Desktop", "sticker_web_replication_assets", "sticker_normal");
|
||||
const originalStickerFixtures: Readonly<Record<string, string>> = {
|
||||
STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"),
|
||||
STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"),
|
||||
@@ -131,6 +132,41 @@ test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first",
|
||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP4-CAN-001 drags distant multi-selected objects as one group", async ({ page }) => {
|
||||
const projectId = uuid(519);
|
||||
const backend = {
|
||||
canvas: canvas([sticker(1, { x: 0.2, y: 0.2 }, 0), sticker(2, { x: 0.8, y: 0.8 }, 1)]),
|
||||
saves: 0,
|
||||
version: 5,
|
||||
};
|
||||
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 point = (x: number, y: number) => ({ x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y });
|
||||
|
||||
await page.getByRole("button", { name: "多选模式" }).click();
|
||||
await page.mouse.click(point(0.2, 0.2).x, point(0.2, 0.2).y);
|
||||
await page.mouse.click(point(0.8, 0.8).x, point(0.8, 0.8).y);
|
||||
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
|
||||
|
||||
await page.mouse.move(point(0.2, 0.2).x, point(0.2, 0.2).y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(point(0.3, 0.3).x, point(0.3, 0.3).y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(0);
|
||||
const [first, second] = backend.canvas.elements.map(({ position }) => position);
|
||||
expect(first?.x).toBeCloseTo(0.3, 6);
|
||||
expect(first?.y).toBeCloseTo(0.3, 6);
|
||||
expect(second?.x).toBeCloseTo(0.9, 6);
|
||||
expect(second?.y).toBeCloseTo(0.9, 6);
|
||||
expect((first?.x ?? 0) - 0.2).toBeCloseTo((second?.x ?? 0) - 0.8, 10);
|
||||
expect((first?.y ?? 0) - 0.2).toBeCloseTo((second?.y ?? 0) - 0.8, 10);
|
||||
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => {
|
||||
const projectId = uuid(520);
|
||||
const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 };
|
||||
|
||||
@@ -212,81 +212,3 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
|
||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||
});
|
||||
|
||||
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
|
||||
const projectId = uuid(760);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const before = await stage.boundingBox();
|
||||
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
||||
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||
const after = await stage.boundingBox();
|
||||
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
||||
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
||||
clientHeight: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
scrollHeight: panel.scrollHeight,
|
||||
}));
|
||||
|
||||
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
|
||||
expect(assetsPanelScroll.overflowY).toBe("auto");
|
||||
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
|
||||
});
|
||||
|
||||
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
|
||||
const projectId = uuid(770);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
|
||||
await page.reload();
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const bounds = await stage.boundingBox();
|
||||
const element = backend.canvas.elements[0];
|
||||
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
|
||||
const positionBefore = structuredClone(element.position);
|
||||
const savesBefore = backend.saves;
|
||||
const clientX = bounds.x + bounds.width * element.position.x;
|
||||
const clientY = bounds.y + bounds.height * element.position.y;
|
||||
|
||||
await page.mouse.move(clientX, clientY);
|
||||
await page.mouse.down();
|
||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||
const selectedBounds = await stage.boundingBox();
|
||||
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
|
||||
clientHeight: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
scrollHeight: panel.scrollHeight,
|
||||
}));
|
||||
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
|
||||
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
|
||||
expect(inspectorScroll.overflowY).toBe("auto");
|
||||
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
|
||||
await page.mouse.move(clientX + 1, clientY);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
|
||||
expect(backend.saves).toBe(savesBefore);
|
||||
|
||||
await page.mouse.move(clientX, clientY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(clientX + 12, clientY);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
|
||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const testBrowserSupportRelease = {
|
||||
appVersion: "1.2.3-test",
|
||||
browsers: [
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7339.1" },
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7339.1", supportedMajorVersions: [150, 151] },
|
||||
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -10,7 +10,7 @@ function release() {
|
||||
return buildFinalReleaseRecord({
|
||||
appVersion: "0.0.0",
|
||||
browsers: [
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151] },
|
||||
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||
],
|
||||
buildCommit: "a".repeat(40),
|
||||
@@ -26,6 +26,32 @@ test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", (
|
||||
assert.equal(record.fixedPort, 43121);
|
||||
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
||||
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
||||
assert.deepEqual(record.browsers[0].supportedMajorVersions, [150, 151]);
|
||||
});
|
||||
|
||||
test("TDD-WP7-REL-001 rejects unsafe or duplicate browser major lists", () => {
|
||||
assert.throws(() => buildFinalReleaseRecord({
|
||||
appVersion: "0.0.0",
|
||||
browsers: [
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 150] },
|
||||
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||
],
|
||||
buildCommit: "a".repeat(40),
|
||||
frozenFromCommit: "b".repeat(40),
|
||||
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||
}), /Google Chrome\.supportedMajorVersions/);
|
||||
assert.throws(() => buildFinalReleaseRecord({
|
||||
appVersion: "0.0.0",
|
||||
browsers: [
|
||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151, 152, 153, 154, 155, 156, 157] },
|
||||
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||
],
|
||||
buildCommit: "a".repeat(40),
|
||||
frozenFromCommit: "b".repeat(40),
|
||||
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||
}), /supportedMajorVersions\.total/);
|
||||
});
|
||||
|
||||
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adjustBackgroundPixels,
|
||||
backgroundDrawPlan,
|
||||
CanvasEditHistory,
|
||||
createEditorCanvasState,
|
||||
cssFilterForBackground,
|
||||
defaultBackgroundAdjustments,
|
||||
deserializeFabricCanvas,
|
||||
switchBackground,
|
||||
@@ -24,6 +27,35 @@ const element = {
|
||||
};
|
||||
|
||||
describe("TDD-WP4-BG-001/002 canvas boundary", () => {
|
||||
it("builds valid filters and distinct contain/crop draw plans", () => {
|
||||
const adjustments = { ...defaultBackgroundAdjustments(), brightness: 25, contrast: 15, saturation: -20 };
|
||||
expect(cssFilterForBackground(adjustments)).toBe("brightness(125%) contrast(115%) saturate(80%)");
|
||||
expect(cssFilterForBackground(defaultBackgroundAdjustments())).toBe("none");
|
||||
|
||||
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "fit" })).toEqual({
|
||||
destination: { height: 50, width: 100, x: 0, y: 25 },
|
||||
source: { height: 200, width: 400, x: 0, y: 0 },
|
||||
});
|
||||
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "crop" })).toEqual({
|
||||
destination: { height: 100, width: 100, x: 0, y: 0 },
|
||||
source: { height: 200, width: 200, x: 100, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("changes pixels for temperature and sharpness without changing alpha", () => {
|
||||
const flat = new Uint8ClampedArray([100, 100, 100, 255]);
|
||||
expect([...adjustBackgroundPixels(flat, 1, 1, { sharpness: 0, temperature: 100 })]).toEqual([135, 108, 65, 255]);
|
||||
|
||||
const edged = new Uint8ClampedArray([
|
||||
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
|
||||
100, 100, 100, 255, 180, 180, 180, 255, 100, 100, 100, 255,
|
||||
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
|
||||
]);
|
||||
const sharpened = adjustBackgroundPixels(edged, 3, 3, { sharpness: 100, temperature: 0 });
|
||||
expect(sharpened[16]).toBe(255);
|
||||
expect(sharpened[19]).toBe(255);
|
||||
});
|
||||
|
||||
it("switches background without moving overlays, resets processing, and re-extracts palette", () => {
|
||||
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" });
|
||||
const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] };
|
||||
|
||||
@@ -73,6 +73,10 @@ describe("TDD-WP4-CAN-001 canvas selection and limit", () => {
|
||||
identity(1).elementId,
|
||||
identity(2).elementId,
|
||||
]);
|
||||
expect(controller.selectAt({ x: 0.2, y: 0.2 }, { append: true, preserveSelection: true })).toEqual([
|
||||
identity(1).elementId,
|
||||
identity(2).elementId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +101,19 @@ describe("TDD-WP4-STK-001 sticker transformations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses one bounded delta for distant selected elements", () => {
|
||||
const controller = new CanvasElementController(state([
|
||||
sticker(1, { position: { x: 0.1, y: 0.2 } }),
|
||||
sticker(2, { position: { x: 0.9, y: 0.8 } }),
|
||||
]));
|
||||
controller.selectIds([identity(1).elementId, identity(2).elementId]);
|
||||
controller.moveSelected({ x: 0.2, y: 0.3 }, { snap: false });
|
||||
expect(controller.value.elements.map(({ position }) => position)).toEqual([
|
||||
{ x: 0.2, y: 0.4 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplicates, reorders and deletes selected stickers while preserving stable resource identity", () => {
|
||||
const controller = new CanvasElementController(state([sticker(1), sticker(2, { position: { x: 0.7, y: 0.7 } })]));
|
||||
controller.selectById(identity(1).elementId);
|
||||
|
||||
Reference in New Issue
Block a user