Compare commits

...

3 Commits

Author SHA1 Message Date
Your Name
db1fc8c3df feat: add PSD force clipping mask option 2026-06-01 18:45:54 +08:00
Your Name
4d2093ef44 Revert "fix: preserve PSD super lock mask editing"
This reverts commit 6d60d53910cf8cb3d65aade75496cf514d3df617.
2026-06-01 18:45:54 +08:00
Your Name
0baa13f7de Revert "fix: clip PSD super lock replacements"
This reverts commit 6e647021cdc52a083cace27801044838f11a5864.
2026-06-01 18:45:54 +08:00
4 changed files with 99 additions and 188 deletions

View File

@ -1780,7 +1780,7 @@ export function PsdTemplatesSettingsClient({
超级锁
</div>
<div className="text-xs text-muted-foreground">
开启后锁定目标图层的位置、层级和删除操作;图片槽位仍可替换图片,并只能在蒙版内拖动替换图。
开启后用户前台无法选中、编辑、拖动或临时删除该图层,鼠标点击会自动穿透到下方图层。
</div>
</div>
<Switch
@ -1792,9 +1792,15 @@ export function PsdTemplatesSettingsClient({
...current,
isSuperLocked: checked,
isRequired: checked ? false : current.isRequired,
isDefaultResultSource: checked
? false
: current.isDefaultResultSource,
allowUserUploadOverride: checked
? true
? false
: current.allowUserUploadOverride,
forceClipMask: checked
? false
: current.forceClipMask,
};
}
@ -1831,6 +1837,9 @@ export function PsdTemplatesSettingsClient({
allowUserUploadOverride: checked
? current.allowUserUploadOverride
: false,
forceClipMask: checked
? current.forceClipMask
: false,
}
: {
...current,
@ -1946,6 +1955,31 @@ export function PsdTemplatesSettingsClient({
/>
</div>
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">强制剪切蒙版</div>
<div className="text-xs text-muted-foreground">
开启后替换图会按当前 PSD 图层原始透明区域剪切,适合需要贴合固定形状的图片槽位。
</div>
</div>
<Switch
checked={layer.isEditable && layer.forceClipMask === true}
disabled={!layer.isEditable || layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
forceClipMask: current.isEditable
? checked
: false,
}
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div>
<Select

View File

@ -872,7 +872,9 @@ function getDefaultSourceImageLayer(
): AgentPsdTemplateImageLayerSlot | null {
const editableImageLayers = layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
return (
@ -910,6 +912,9 @@ function createInitialState(
for (const layer of template.layers) {
layerOrderIds.push(layer.id);
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
if (
isAgentPsdImageLayerSlot(layer) &&
layer.id === defaultSourceImageLayerId &&
@ -918,9 +923,6 @@ function createInitialState(
imageAssignments[layer.id] = sourceImageUrl;
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
}
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
textAssignments[layer.id] = layer.defaultText;
}
@ -1471,17 +1473,17 @@ function renderTextLayerPreviewContent(
);
}
function getAssignedImageMaskStyle(params: {
layer: AgentPsdTemplateImageLayerSlot;
maskSourceUrl?: string;
}): CSSProperties {
const normalizedMaskUrl = String(
params.layer.maskUrl || params.maskSourceUrl || ""
).trim();
function getAssignedImageMaskStyle(
layer: AgentPsdTemplateImageLayerSlot
): CSSProperties {
const fallbackMaskUrl = layer.forceClipMask
? getPsdLayerPreviewSourceUrl(layer)
: "";
const normalizedMaskUrl = String(layer.maskUrl || fallbackMaskUrl || "").trim();
const hasMask = Boolean(normalizedMaskUrl);
const maskImageUrl = hasMask
? `url(${buildPsdLayerPreviewUrl(normalizedMaskUrl, {
width: params.layer.width,
width: layer.width,
quality: 72,
})})`
: undefined;
@ -1741,13 +1743,6 @@ export function AgentPsdTemplateEditorDialog({
startClientY: number;
startOffset: AgentPsdLayerPositionOffset;
} | null>(null);
const imagePlacementDragStateRef = useRef<{
layerId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startPlacement: AgentPsdImagePlacement;
} | null>(null);
const textDragStateRef = useRef<{
layerId: string;
pointerId: number;
@ -1954,7 +1949,9 @@ export function AgentPsdTemplateEditorDialog({
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
),
[selectedTemplateLayers]
);
@ -2049,11 +2046,11 @@ export function AgentPsdTemplateEditorDialog({
);
const orderedEditableImageLayers = useMemo(
() =>
orderedPreviewLayers.filter(
orderedEditableLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
isAgentPsdImageLayerSlot(layer)
),
[orderedPreviewLayers]
[orderedEditableLayers]
);
const orderedEditableTextLayers = useMemo(
() =>
@ -2204,24 +2201,17 @@ export function AgentPsdTemplateEditorDialog({
const layer =
selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
null;
return layer;
return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
}, [selectedLayerId, selectedTemplateLayers]);
const isSelectedLayerSuperLocked = Boolean(
selectedLayer && isAgentPsdLayerSuperLocked(selectedLayer)
);
const isSelectedLayerHidden = Boolean(
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
);
const selectedImageLayer =
selectedLayer &&
selectedLayer.isEditable &&
isAgentPsdImageLayerSlot(selectedLayer)
selectedLayer && isAgentPsdImageLayerSlot(selectedLayer)
? selectedLayer
: null;
const selectedTextLayer =
selectedLayer &&
!isSelectedLayerSuperLocked &&
isAgentPsdTextLayerSlot(selectedLayer)
selectedLayer && isAgentPsdTextLayerSlot(selectedLayer)
? selectedLayer
: null;
const selectedImageAssignmentUrl = selectedImageLayer
@ -2273,9 +2263,8 @@ export function AgentPsdTemplateEditorDialog({
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
);
}
const selectedLayerPositionPanel =
selectedLayer && !isSelectedLayerSuperLocked ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
const selectedLayerPositionPanel = selectedLayer ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">图层层级</div>
@ -2499,7 +2488,7 @@ export function AgentPsdTemplateEditorDialog({
槽位内图片位置
</div>
<div className="mt-1.5 text-white/50 text-xs">
目标图层保持固定;拖动预览中的替换图可在蒙版内微调位置。
图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。
</div>
</div>
<ImageIcon className="h-5 w-5 shrink-0 text-orange-300" />
@ -3467,72 +3456,6 @@ export function AgentPsdTemplateEditorDialog({
window.addEventListener("pointercancel", handlePointerUp);
}
function handleAssignedImagePlacementDragStart(
layer: AgentPsdTemplateImageLayerSlot,
event: ReactPointerEvent<HTMLElement>
) {
const assignmentUrl = String(
effectiveImageAssignments[layer.id] || ""
).trim();
if (!assignmentUrl) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
imagePlacementDragStateRef.current = {
layerId: layer.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startPlacement: normalizeAgentPsdImagePlacement(
effectiveImagePlacements[layer.id]
),
};
const nextTarget = event.currentTarget;
try {
nextTarget.setPointerCapture?.(event.pointerId);
} catch (error) {
console.warn(
"[AgentPsdTemplateEditorDialog] setPointerCapture failed:",
error
);
}
const handlePointerMove = (moveEvent: PointerEvent) => {
const dragState = imagePlacementDragStateRef.current;
if (!dragState || dragState.layerId !== layer.id) {
return;
}
updateImagePlacement(layer.id, () => ({
...dragState.startPlacement,
offsetX:
dragState.startPlacement.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startPlacement.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (imagePlacementDragStateRef.current?.pointerId === upEvent.pointerId) {
imagePlacementDragStateRef.current = null;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
function handleTextDragStart(
layer: AgentPsdTemplateTextLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
@ -4228,80 +4151,58 @@ export function AgentPsdTemplateEditorDialog({
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl =
effectiveImageAssignments[layer.id];
const hasAssignedImage = Boolean(
String(assignedUrl || "").trim()
);
const canDragAssignedImage =
isLayerSuperLocked && hasAssignedImage;
const layerImageUrl =
assignedUrl ||
getPsdLayerPreviewSourceUrl(layer);
if (!layerImageUrl) {
return null;
}
const layerPreviewSourceUrl =
getPsdLayerPreviewSourceUrl(layer);
const assignedImageContainerStyle = assignedUrl
? {
...layerBoxStyle,
...getAssignedImageMaskStyle({
layer,
maskSourceUrl: layerPreviewSourceUrl,
}),
...getAssignedImageMaskStyle(layer),
}
: layerBoxStyle;
const previewZIndex = hasAssignedImage
const previewZIndex = assignedUrl
? Math.max(baseZIndex, renderBaseZIndex + 1)
: baseZIndex;
return (
<div
className={`absolute overflow-hidden ${
isLayerSuperLocked && !hasAssignedImage
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={(event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}}
onPointerDown={(event) => {
if (canDragAssignedImage) {
handleAssignedImagePlacementDragStart(
layer,
event
);
return;
}
if (!isLayerSuperLocked) {
handleLayerDragStart(layer, event);
}
}}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
isLayerSuperLocked
? undefined
: handleLayerDragStart(layer, event)
}
style={{
...assignedImageContainerStyle,
zIndex: previewZIndex,
cursor: canDragAssignedImage
? imagePlacementDragStateRef.current
?.layerId === layer.id
? "grabbing"
: "grab"
: isLayerSuperLocked
cursor:
isLayerSuperLocked
? "default"
: layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction:
isLayerSuperLocked && !hasAssignedImage
? "auto"
: "none",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={
canDragAssignedImage
? `${getLayerLabel(layer)}:${layer.layerName}(目标已锁定,可拖动替换图)`
: `${getLayerLabel(layer)}:${layer.layerName}`
}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<img
alt={layer.layerName}
@ -4542,11 +4443,6 @@ export function AgentPsdTemplateEditorDialog({
effectiveImageAssignments[layer.id] || ""
).trim()
);
const effectiveBaseZIndex = hasAssignedImage
? Math.max(baseZIndex, renderBaseZIndex + 1)
: baseZIndex;
const isLayerSuperLocked =
isAgentPsdLayerSuperLocked(layer);
return isSelected && hasAssignedImage ? (
<div
@ -4556,7 +4452,7 @@ export function AgentPsdTemplateEditorDialog({
style={{
...frameStyle,
zIndex:
effectiveBaseZIndex +
baseZIndex +
SELECTED_LAYER_INTERACTION_Z_BOOST,
background: "rgba(249,115,22,0.08)",
cursor:
@ -4574,22 +4470,13 @@ export function AgentPsdTemplateEditorDialog({
className="absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) => {
if (isLayerSuperLocked && hasAssignedImage) {
handleAssignedImagePlacementDragStart(
layer,
event
);
return;
}
if (!isLayerSuperLocked) {
handleLayerDragStart(layer, event);
}
}}
onPointerDown={(event) =>
handleLayerDragStart(layer, event)
}
style={{
...frameStyle,
zIndex:
effectiveBaseZIndex +
baseZIndex +
(isSelected
? SELECTED_LAYER_INTERACTION_Z_BOOST
: 0),
@ -4597,15 +4484,10 @@ export function AgentPsdTemplateEditorDialog({
? "rgba(249,115,22,0.08)"
: "transparent",
cursor:
isLayerSuperLocked && hasAssignedImage
? imagePlacementDragStateRef.current
?.layerId === layer.id
? "grabbing"
: "grab"
: layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
@ -4626,9 +4508,6 @@ export function AgentPsdTemplateEditorDialog({
);
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const effectiveBaseZIndex = hasAssignedImage
? Math.max(baseZIndex, renderBaseZIndex + 1)
: baseZIndex;
if (!isSelected || !hasAssignedImage) {
return null;
}
@ -4656,7 +4535,7 @@ export function AgentPsdTemplateEditorDialog({
44
),
zIndex:
effectiveBaseZIndex +
baseZIndex +
SELECTED_LAYER_CONTROLS_Z_BOOST,
}}
>

View File

@ -49,6 +49,7 @@ export interface AgentPsdTemplateImageLayerSlot
slotType: "image";
isDefaultResultSource: boolean;
allowUserUploadOverride: boolean;
forceClipMask?: boolean;
replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds;
maskUrl?: string;
@ -415,6 +416,7 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
isEditable && !isSuperLocked && record.isDefaultResultSource === true,
allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
forceClipMask: isEditable && !isSuperLocked && record.forceClipMask === true,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds,

View File

@ -913,10 +913,7 @@ export async function renderAgentPsdTemplate(params: {
}
for (const layer of [...orderedTemplateLayers].reverse()) {
if (
shouldUseRenderSegments &&
(isAgentPsdTemplateLayerUserEditable(layer) || hasImageAssignment(layer))
) {
if (shouldUseRenderSegments && isAgentPsdTemplateLayerUserEditable(layer)) {
await appendRenderSegmentBeforeLayer(layer.id);
}
@ -924,7 +921,7 @@ export async function renderAgentPsdTemplate(params: {
continue;
}
if (shouldRenderAsStaticLayer(layer)) {
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
continue;
}
@ -1035,8 +1032,7 @@ export async function renderAgentPsdTemplate(params: {
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const shouldUseLayerSourceAsMask =
!layer.maskUrl && isAgentPsdLayerSuperLocked(layer);
const shouldUseLayerSourceAsMask = !layer.maskUrl && layer.forceClipMask;
if (layer.maskUrl || shouldUseLayerSourceAsMask) {
try {
const rawMaskBuffer = layer.maskUrl