Layout change

This commit is contained in:
Dariusz L
2025-07-28 15:57:28 +02:00
parent bb687a768b
commit f57b9f6b58
8 changed files with 805 additions and 338 deletions

View File

@@ -38,6 +38,27 @@ export class CanvasRenderer {
});
ctx.restore();
}
/**
* Helper function to draw rectangle with stroke style
* @param ctx Canvas context
* @param rect Rectangle bounds {x, y, width, height}
* @param options Styling options
*/
drawStyledRect(ctx, rect, options = {}) {
const { strokeStyle = "rgba(255, 255, 255, 0.8)", lineWidth = 2, dashPattern = null } = options;
ctx.save();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth / this.canvas.viewport.zoom;
if (dashPattern) {
const scaledDash = dashPattern.map((d) => d / this.canvas.viewport.zoom);
ctx.setLineDash(scaledDash);
}
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
if (dashPattern) {
ctx.setLineDash([]);
}
ctx.restore();
}
render() {
if (this.renderAnimationFrame) {
this.isDirty = true;
@@ -148,13 +169,11 @@ export class CanvasRenderer {
const interaction = this.canvas.interaction;
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, rect, {
strokeStyle: 'rgba(0, 255, 0, 0.8)',
lineWidth: 2,
dashPattern: [8, 4]
});
if (rect.width > 0 && rect.height > 0) {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2;
@@ -166,13 +185,11 @@ export class CanvasRenderer {
}
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, rect, {
strokeStyle: 'rgba(0, 150, 255, 0.8)',
lineWidth: 2,
dashPattern: [10, 5]
});
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
@@ -327,13 +344,11 @@ export class CanvasRenderer {
width: baseWidth + ext.left + ext.right,
height: baseHeight + ext.top + ext.bottom
};
ctx.save();
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // Yellow color for preview
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.strokeRect(previewBounds.x, previewBounds.y, previewBounds.width, previewBounds.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, previewBounds, {
strokeStyle: 'rgba(255, 255, 0, 0.8)',
lineWidth: 3,
dashPattern: [8, 4]
});
}
drawPendingGenerationAreas(ctx) {
const areasToDraw = [];
@@ -354,12 +369,11 @@ export class CanvasRenderer {
}
// 3. Draw all collected areas
areasToDraw.forEach(area => {
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(area.x, area.y, area.width, area.height);
ctx.restore();
this.drawStyledRect(ctx, area, {
strokeStyle: 'rgba(0, 150, 255, 0.9)',
lineWidth: 3,
dashPattern: [12, 6]
});
});
}
drawMaskAreaBounds(ctx) {
@@ -375,12 +389,11 @@ export class CanvasRenderer {
width: maskTool.getMask().width,
height: maskTool.getMask().height
};
ctx.save();
ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)'; // Red color for mask area bounds
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([6 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(maskBounds.x, maskBounds.y, maskBounds.width, maskBounds.height);
ctx.setLineDash([]);
this.drawStyledRect(ctx, maskBounds, {
strokeStyle: 'rgba(255, 100, 100, 0.7)',
lineWidth: 2,
dashPattern: [6, 6]
});
// Add text label to show this is the mask drawing area
const textWorldX = maskBounds.x + maskBounds.width / 2;
const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom);
@@ -389,6 +402,5 @@ export class CanvasRenderer {
backgroundColor: "rgba(255, 100, 100, 0.8)",
padding: 8
});
ctx.restore();
}
}

View File

@@ -65,20 +65,14 @@ async function createCanvasWidget(node, widget, app) {
},
}, [
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
$el("button.painter-button.icon-button", {
id: `open-editor-btn-${node.id}`,
textContent: "⛶",
title: "Open in Editor",
style: { minWidth: "40px", maxWidth: "40px", fontWeight: "bold" },
}),
$el("button.painter-button", {
$el("button.painter-button.icon-button", {
textContent: "?",
title: "Show shortcuts",
style: {
minWidth: "30px",
maxWidth: "30px",
fontWeight: "bold",
},
onmouseenter: (e) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target, content);
@@ -131,38 +125,63 @@ async function createCanvasWidget(node, widget, app) {
canvas.canvasLayers.handlePaste(addMode);
}
}),
$el("button.painter-button", {
id: `clipboard-toggle-${node.id}`,
textContent: "📋 System",
title: "Toggle clipboard source: System Clipboard",
style: {
minWidth: "100px",
fontSize: "11px",
backgroundColor: "#4a4a4a"
},
onclick: (e) => {
const button = e.target;
if (canvas.canvasLayers.clipboardPreference === 'system') {
canvas.canvasLayers.clipboardPreference = 'clipspace';
button.textContent = "📋 Clipspace";
button.title = "Toggle clipboard source: ComfyUI Clipspace";
button.style.backgroundColor = "#4a6cd4";
(() => {
// Modern clipboard switch
// Initial state: checked = clipspace, unchecked = system
const isClipspace = canvas.canvasLayers.clipboardPreference === 'clipspace';
const switchId = `clipboard-switch-${node.id}`;
const switchEl = $el("label.clipboard-switch", { id: switchId }, [
$el("input", {
type: "checkbox",
checked: isClipspace,
onchange: (e) => {
const checked = e.target.checked;
canvas.canvasLayers.clipboardPreference = checked ? 'clipspace' : 'system';
// For accessibility, update ARIA label
switchEl.setAttribute('aria-label', checked ? "Clipboard: Clipspace" : "Clipboard: System");
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
}
}),
$el("span.switch-track"),
$el("span.switch-labels", {}, [
$el("span.text-clipspace", {}, ["Clipspace"]),
$el("span.text-system", {}, ["System"])
]),
$el("span.switch-knob", {}, [
$el("span.switch-icon")
])
]);
// Tooltip logic
switchEl.addEventListener("mouseenter", (e) => {
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon and text update on toggle
const input = switchEl.querySelector('input[type="checkbox"]');
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
const updateSwitchView = (isClipspace) => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
const icon = iconLoader.getIcon(iconTool);
if (icon instanceof HTMLImageElement) {
knobIcon.innerHTML = '';
const clonedIcon = icon.cloneNode();
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIcon.appendChild(clonedIcon);
}
else {
canvas.canvasLayers.clipboardPreference = 'system';
button.textContent = "📋 System";
button.title = "Toggle clipboard source: System Clipboard";
button.style.backgroundColor = "#4a4a4a";
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
}
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
},
onmouseenter: (e) => {
const currentPreference = canvas.canvasLayers.clipboardPreference;
const tooltipContent = currentPreference === 'system' ? systemClipboardTooltip : clipspaceClipboardTooltip;
showTooltip(e.target, tooltipContent);
},
onmouseleave: hideTooltip
})
};
input.addEventListener('change', () => updateSwitchView(input.checked));
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace);
});
return switchEl;
})()
]),
]),
$el("div.painter-separator"),
@@ -440,8 +459,15 @@ async function createCanvasWidget(node, widget, app) {
min: "1",
max: "200",
value: "20",
oninput: (e) => canvas.maskTool.setBrushSize(parseInt(e.target.value))
})
oninput: (e) => {
const value = e.target.value;
canvas.maskTool.setBrushSize(parseInt(value));
const valueEl = document.getElementById('brush-size-value');
if (valueEl)
valueEl.textContent = `${value}px`;
}
}),
$el("div.slider-value", { id: "brush-size-value" }, ["20px"])
]),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "brush-strength-slider", textContent: "Strength:" }),
@@ -452,8 +478,15 @@ async function createCanvasWidget(node, widget, app) {
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => canvas.maskTool.setBrushStrength(parseFloat(e.target.value))
})
oninput: (e) => {
const value = e.target.value;
canvas.maskTool.setBrushStrength(parseFloat(value));
const valueEl = document.getElementById('brush-strength-value');
if (valueEl)
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", { id: "brush-strength-value" }, ["50%"])
]),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "brush-hardness-slider", textContent: "Hardness:" }),
@@ -464,8 +497,15 @@ async function createCanvasWidget(node, widget, app) {
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => canvas.maskTool.setBrushHardness(parseFloat(e.target.value))
})
oninput: (e) => {
const value = e.target.value;
canvas.maskTool.setBrushHardness(parseFloat(value));
const valueEl = document.getElementById('brush-hardness-value');
if (valueEl)
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", { id: "brush-hardness-value" }, ["50%"])
]),
$el("button.painter-button.mask-control", {
textContent: "Clear Mask",
@@ -481,10 +521,9 @@ async function createCanvasWidget(node, widget, app) {
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
$el("button.painter-button.success", {
textContent: "Run GC",
title: "Run Garbage Collection to clean unused images",
style: { backgroundColor: "#4a7c59", borderColor: "#3a6c49" },
onclick: async () => {
try {
const stats = canvas.imageReferenceManager.getStats();
@@ -500,10 +539,9 @@ async function createCanvasWidget(node, widget, app) {
}
}
}),
$el("button.painter-button", {
$el("button.painter-button.danger", {
textContent: "Clear Cache",
title: "Clear all saved canvas states from browser storage",
style: { backgroundColor: "#c54747", borderColor: "#a53737" },
onclick: async () => {
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
try {
@@ -736,36 +774,33 @@ async function createCanvasWidget(node, widget, app) {
let backdrop = null;
let originalParent = null;
let isEditorOpen = false;
let viewportAdjustment = { x: 0, y: 0 };
/**
* Adjusts the viewport to keep the content centered when the container size changes.
* @param rectA The original rectangle.
* @param rectB The new rectangle.
* @param direction Determines whether to apply the adjustment for opening (-1) or closing (1).
* Adjusts the viewport when entering fullscreen mode.
*/
const adjustViewportForCentering = (rectA, rectB, direction) => {
if (!rectA || !rectB)
return;
const widthDiff = rectB.width - rectA.width;
const heightDiff = rectB.height - rectA.height;
const adjustViewportOnOpen = (originalRect) => {
const fullscreenRect = canvasContainer.getBoundingClientRect();
const widthDiff = fullscreenRect.width - originalRect.width;
const heightDiff = fullscreenRect.height - originalRect.height;
const adjustX = (widthDiff / 2) / canvas.viewport.zoom;
const adjustY = (heightDiff / 2) / canvas.viewport.zoom;
canvas.viewport.x -= adjustX * direction;
canvas.viewport.y -= adjustY * direction;
const action = direction === 1 ? 'OPENING' : 'CLOSING';
log.info(`FULLSCREEN ${action} - Viewport adjusted for centering:`, {
widthDiff, heightDiff, adjustX, adjustY,
viewport_after: { x: canvas.viewport.x, y: canvas.viewport.y, zoom: canvas.viewport.zoom }
});
// Store the adjustment
viewportAdjustment = { x: adjustX, y: adjustY };
// Apply the adjustment
canvas.viewport.x -= viewportAdjustment.x;
canvas.viewport.y -= viewportAdjustment.y;
};
/**
* Restores the viewport when exiting fullscreen mode.
*/
const adjustViewportOnClose = () => {
// Apply the stored adjustment in reverse
canvas.viewport.x += viewportAdjustment.x;
canvas.viewport.y += viewportAdjustment.y;
// Reset adjustment
viewportAdjustment = { x: 0, y: 0 };
};
const closeEditor = () => {
// Get fullscreen rect BEFORE removing from DOM
const fullscreenRect = backdrop?.querySelector('.painter-modal-content')?.getBoundingClientRect();
const currentRect = originalParent?.getBoundingClientRect();
log.info(`FULLSCREEN CLOSING - Window sizes:`, {
current: { width: currentRect?.width, height: currentRect?.height },
fullscreen: { width: fullscreenRect?.width, height: fullscreenRect?.height },
viewport_before: { x: canvas.viewport.x, y: canvas.viewport.y, zoom: canvas.viewport.zoom }
});
if (originalParent && backdrop) {
originalParent.appendChild(mainContainer);
document.body.removeChild(backdrop);
@@ -776,12 +811,7 @@ async function createCanvasWidget(node, widget, app) {
// Remove ESC key listener when editor closes
document.removeEventListener('keydown', handleEscKey);
setTimeout(() => {
// Use the actual canvas container for centering calculation
const currentCanvasContainer = originalParent.querySelector('.painterCanvasContainer.painter-container');
const fullscreenCanvasContainer = backdrop.querySelector('.painterCanvasContainer.painter-container');
const currentRect = currentCanvasContainer.getBoundingClientRect();
const fullscreenRect = fullscreenCanvasContainer.getBoundingClientRect();
adjustViewportForCentering(currentRect, fullscreenRect, -1);
adjustViewportOnClose();
canvas.render();
if (node.onResize) {
node.onResize();
@@ -794,7 +824,6 @@ async function createCanvasWidget(node, widget, app) {
e.preventDefault();
e.stopPropagation();
closeEditor();
log.info("Fullscreen editor closed via ESC key");
}
};
openEditorBtn.onclick = () => {
@@ -802,6 +831,7 @@ async function createCanvasWidget(node, widget, app) {
closeEditor();
return;
}
const originalRect = canvasContainer.getBoundingClientRect();
originalParent = mainContainer.parentElement;
if (!originalParent) {
log.error("Could not find original parent of the canvas container!");
@@ -818,12 +848,7 @@ async function createCanvasWidget(node, widget, app) {
// Add ESC key listener when editor opens
document.addEventListener('keydown', handleEscKey);
setTimeout(() => {
// Use the actual canvas container for centering calculation
const originalCanvasContainer = originalParent.querySelector('.painterCanvasContainer.painter-container');
const fullscreenCanvasContainer = modalContent.querySelector('.painterCanvasContainer.painter-container');
const originalRect = originalCanvasContainer.getBoundingClientRect();
const fullscreenRect = fullscreenCanvasContainer.getBoundingClientRect();
adjustViewportForCentering(originalRect, fullscreenRect, 1);
adjustViewportOnOpen(originalRect);
canvas.render();
if (node.onResize) {
node.onResize();

View File

@@ -1,54 +1,96 @@
.painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
background-color: #444;
border: 1px solid #555;
border-radius: 5px;
color: #e0e0e0;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.2s ease-in-out;
min-width: 80px;
text-align: center;
margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
background-color: #555;
border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
.painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
transform: translateY(1px);
background-color: #3a3a3a;
transform: translateY(0);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
}
.painter-button:disabled,
.painter-button:disabled:hover {
background: #555;
color: #888;
background-color: #3a3a3a;
color: #777;
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: #444;
border-color: #4a4a4a;
opacity: 0.6;
}
.painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
border-color: #2a4cb4;
background-color: #3a76d6;
border-color: #2a6ac4;
color: #fff;
text-shadow: none;
}
.painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
background-color: #4a86e4;
border-color: #3a76d6;
}
.painter-button.success {
background-color: #4a7c59;
border-color: #3a6c49;
color: #fff;
}
.painter-button.success:hover {
background-color: #5a8c69;
border-color: #4a7c59;
}
.painter-button.danger {
background-color: #c54747;
border-color: #a53737;
color: #fff;
}
.painter-button.danger:hover {
background-color: #d55757;
border-color: #c54747;
}
.painter-button.icon-button {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
font-size: 16px;
line-height: 30px; /* Match height */
display: flex;
align-items: center;
justify-content: center;
}
.painter-controls {
background: linear-gradient(to bottom, #404040, #383838);
border-bottom: 1px solid #2a2a2a;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 8px;
background-color: #2f2f2f;
border-bottom: 1px solid #202020;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
padding: 10px;
display: flex;
gap: 6px;
gap: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
@@ -56,57 +98,198 @@
.painter-slider-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 4px;
color: #fff;
font-size: 12px;
min-width: 100px;
}
.painter-slider-container input[type="range"] {
-webkit-appearance: none;
width: 80px;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
.painter-slider-container input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
.slider-value {
font-size: 11px;
color: #bbb;
margin-top: 2px;
min-height: 14px;
text-align: center;
}
.painter-button-group {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(0,0,0,0.2);
padding: 4px;
gap: 4px;
background-color: transparent;
padding: 0;
border-radius: 6px;
}
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
gap: 4px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
height: 30px; /* Match switch height */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
/* --- Clipboard Switch Modern --- */
.clipboard-switch {
position: relative;
width: 90px;
height: 30px;
box-sizing: border-box;
background-color: #444;
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background-color 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.clipboard-switch:hover {
background-color: #555;
border-color: #666;
}
.clipboard-switch:active {
background-color: #3a3a3a;
}
.clipboard-switch input[type="checkbox"] {
display: none;
}
.clipboard-switch .switch-track {
display: none;
}
.clipboard-switch .switch-knob {
position: absolute;
top: 2px;
left: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #5a5a5a;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 2;
}
.clipboard-switch .switch-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: #e0e0e0;
pointer-events: none;
z-index: 1;
transition: opacity 0.3s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace,
.clipboard-switch .switch-labels .text-system {
position: absolute;
transition: opacity 0.2s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
.clipboard-switch .switch-knob .switch-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.clipboard-switch .switch-knob .switch-icon img {
width: 100%;
height: 100%;
}
/* Checked state */
.clipboard-switch:has(input:checked) {
background-color: #3a76d6;
border-color: #2a6ac4;
}
.clipboard-switch:has(input:checked):hover {
background-color: #4a86e4;
border-color: #3a76d6;
}
.clipboard-switch input:checked ~ .switch-knob {
left: calc(100% - 26px);
background-color: #fff;
}
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
filter: invert(35%) sepia(100%) saturate(1500%) hue-rotate(200deg) brightness(90%) contrast(100%);
}
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
opacity: 1;
color: #fff;
padding-right: 20px;
}
.clipboard-switch input:checked ~ .switch-labels .text-system {
opacity: 0;
}
.painter-separator {
width: 1px;
height: 28px;
background-color: #2a2a2a;
height: 24px;
background-color: #444;
margin: 0 8px;
}

View File

@@ -16,10 +16,16 @@ export const LAYERFORGE_TOOLS = {
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings'
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
};
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 7H7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H7V9h10v10z"/><path d="M19 3H9c-1.1 0-2 .9-2 2v2h2V5h10v10h-2v2h2c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`;
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,

View File

@@ -59,6 +59,37 @@ export class CanvasRenderer {
ctx.restore();
}
/**
* Helper function to draw rectangle with stroke style
* @param ctx Canvas context
* @param rect Rectangle bounds {x, y, width, height}
* @param options Styling options
*/
drawStyledRect(ctx: any, rect: any, options: any = {}) {
const {
strokeStyle = "rgba(255, 255, 255, 0.8)",
lineWidth = 2,
dashPattern = null
} = options;
ctx.save();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth / this.canvas.viewport.zoom;
if (dashPattern) {
const scaledDash = dashPattern.map((d: number) => d / this.canvas.viewport.zoom);
ctx.setLineDash(scaledDash);
}
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
if (dashPattern) {
ctx.setLineDash([]);
}
ctx.restore();
}
render() {
if (this.renderAnimationFrame) {
this.isDirty = true;
@@ -187,13 +218,11 @@ export class CanvasRenderer {
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, rect, {
strokeStyle: 'rgba(0, 255, 0, 0.8)',
lineWidth: 2,
dashPattern: [8, 4]
});
if (rect.width > 0 && rect.height > 0) {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2;
@@ -207,13 +236,11 @@ export class CanvasRenderer {
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, rect, {
strokeStyle: 'rgba(0, 150, 255, 0.8)',
lineWidth: 2,
dashPattern: [10, 5]
});
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2;
@@ -397,13 +424,11 @@ export class CanvasRenderer {
height: baseHeight + ext.top + ext.bottom
};
ctx.save();
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // Yellow color for preview
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.strokeRect(previewBounds.x, previewBounds.y, previewBounds.width, previewBounds.height);
ctx.setLineDash([]);
ctx.restore();
this.drawStyledRect(ctx, previewBounds, {
strokeStyle: 'rgba(255, 255, 0, 0.8)',
lineWidth: 3,
dashPattern: [8, 4]
});
}
drawPendingGenerationAreas(ctx: any) {
@@ -429,12 +454,11 @@ export class CanvasRenderer {
// 3. Draw all collected areas
areasToDraw.forEach(area => {
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(area.x, area.y, area.width, area.height);
ctx.restore();
this.drawStyledRect(ctx, area, {
strokeStyle: 'rgba(0, 150, 255, 0.9)',
lineWidth: 3,
dashPattern: [12, 6]
});
});
}
@@ -454,12 +478,11 @@ export class CanvasRenderer {
height: maskTool.getMask().height
};
ctx.save();
ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)'; // Red color for mask area bounds
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([6 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(maskBounds.x, maskBounds.y, maskBounds.width, maskBounds.height);
ctx.setLineDash([]);
this.drawStyledRect(ctx, maskBounds, {
strokeStyle: 'rgba(255, 100, 100, 0.7)',
lineWidth: 2,
dashPattern: [6, 6]
});
// Add text label to show this is the mask drawing area
const textWorldX = maskBounds.x + maskBounds.width / 2;
@@ -470,7 +493,5 @@ export class CanvasRenderer {
backgroundColor: "rgba(255, 100, 100, 0.8)",
padding: 8
});
ctx.restore();
}
}

View File

@@ -90,20 +90,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
},
}, [
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
$el("button.painter-button.icon-button", {
id: `open-editor-btn-${node.id}`,
textContent: "⛶",
title: "Open in Editor",
style: {minWidth: "40px", maxWidth: "40px", fontWeight: "bold"},
}),
$el("button.painter-button", {
$el("button.painter-button.icon-button", {
textContent: "?",
title: "Show shortcuts",
style: {
minWidth: "30px",
maxWidth: "30px",
fontWeight: "bold",
},
onmouseenter: (e: MouseEvent) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target as HTMLElement, content);
@@ -155,37 +149,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
canvas.canvasLayers.handlePaste(addMode);
}
}),
$el("button.painter-button", {
id: `clipboard-toggle-${node.id}`,
textContent: "📋 System",
title: "Toggle clipboard source: System Clipboard",
style: {
minWidth: "100px",
fontSize: "11px",
backgroundColor: "#4a4a4a"
},
onclick: (e: MouseEvent) => {
const button = e.target as HTMLButtonElement;
if (canvas.canvasLayers.clipboardPreference === 'system') {
canvas.canvasLayers.clipboardPreference = 'clipspace';
button.textContent = "📋 Clipspace";
button.title = "Toggle clipboard source: ComfyUI Clipspace";
button.style.backgroundColor = "#4a6cd4";
} else {
canvas.canvasLayers.clipboardPreference = 'system';
button.textContent = "📋 System";
button.title = "Toggle clipboard source: System Clipboard";
button.style.backgroundColor = "#4a4a4a";
}
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
},
onmouseenter: (e: MouseEvent) => {
const currentPreference = canvas.canvasLayers.clipboardPreference;
const tooltipContent = currentPreference === 'system' ? systemClipboardTooltip : clipspaceClipboardTooltip;
showTooltip(e.target as HTMLElement, tooltipContent);
},
onmouseleave: hideTooltip
})
(() => {
// Modern clipboard switch
// Initial state: checked = clipspace, unchecked = system
const isClipspace = canvas.canvasLayers.clipboardPreference === 'clipspace';
const switchId = `clipboard-switch-${node.id}`;
const switchEl = $el("label.clipboard-switch", { id: switchId }, [
$el("input", {
type: "checkbox",
checked: isClipspace,
onchange: (e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
canvas.canvasLayers.clipboardPreference = checked ? 'clipspace' : 'system';
// For accessibility, update ARIA label
switchEl.setAttribute('aria-label', checked ? "Clipboard: Clipspace" : "Clipboard: System");
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
}
}),
$el("span.switch-track"),
$el("span.switch-labels", {}, [
$el("span.text-clipspace", {}, ["Clipspace"]),
$el("span.text-system", {}, ["System"])
]),
$el("span.switch-knob", {}, [
$el("span.switch-icon")
])
]);
// Tooltip logic
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon and text update on toggle
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
const updateSwitchView = (isClipspace: boolean) => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
const icon = iconLoader.getIcon(iconTool);
if (icon instanceof HTMLImageElement) {
knobIcon.innerHTML = '';
const clonedIcon = icon.cloneNode() as HTMLImageElement;
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIcon.appendChild(clonedIcon);
} else {
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
}
};
input.addEventListener('change', () => updateSwitchView(input.checked));
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace);
});
return switchEl;
})()
]),
]),
@@ -477,8 +502,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
min: "1",
max: "200",
value: "20",
oninput: (e: Event) => canvas.maskTool.setBrushSize(parseInt((e.target as HTMLInputElement).value))
})
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushSize(parseInt(value));
const valueEl = document.getElementById('brush-size-value');
if (valueEl) valueEl.textContent = `${value}px`;
}
}),
$el("div.slider-value", {id: "brush-size-value"}, ["20px"])
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-strength-slider", textContent: "Strength:"}),
@@ -489,8 +520,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => canvas.maskTool.setBrushStrength(parseFloat((e.target as HTMLInputElement).value))
})
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushStrength(parseFloat(value));
const valueEl = document.getElementById('brush-strength-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", {id: "brush-strength-value"}, ["50%"])
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-hardness-slider", textContent: "Hardness:"}),
@@ -501,8 +538,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => canvas.maskTool.setBrushHardness(parseFloat((e.target as HTMLInputElement).value))
})
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushHardness(parseFloat(value));
const valueEl = document.getElementById('brush-hardness-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", {id: "brush-hardness-value"}, ["50%"])
]),
$el("button.painter-button.mask-control", {
textContent: "Clear Mask",
@@ -519,10 +562,9 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
$el("button.painter-button.success", {
textContent: "Run GC",
title: "Run Garbage Collection to clean unused images",
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
onclick: async () => {
try {
const stats = canvas.imageReferenceManager.getStats();
@@ -540,10 +582,9 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
}
}
}),
$el("button.painter-button", {
$el("button.painter-button.danger", {
textContent: "Clear Cache",
title: "Clear all saved canvas states from browser storage",
style: {backgroundColor: "#c54747", borderColor: "#a53737"},
onclick: async () => {
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
try {
@@ -796,43 +837,41 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
let backdrop: HTMLDivElement | null = null;
let originalParent: HTMLElement | null = null;
let isEditorOpen = false;
let viewportAdjustment = { x: 0, y: 0 };
/**
* Adjusts the viewport to keep the content centered when the container size changes.
* @param rectA The original rectangle.
* @param rectB The new rectangle.
* @param direction Determines whether to apply the adjustment for opening (-1) or closing (1).
* Adjusts the viewport when entering fullscreen mode.
*/
const adjustViewportForCentering = (rectA: DOMRect, rectB: DOMRect, direction: 1 | -1) => {
if (!rectA || !rectB) return;
const adjustViewportOnOpen = (originalRect: DOMRect) => {
const fullscreenRect = canvasContainer.getBoundingClientRect();
const widthDiff = fullscreenRect.width - originalRect.width;
const heightDiff = fullscreenRect.height - originalRect.height;
const widthDiff = rectB.width - rectA.width;
const heightDiff = rectB.height - rectA.height;
const adjustX = (widthDiff / 2) / canvas.viewport.zoom;
const adjustY = (heightDiff / 2) / canvas.viewport.zoom;
canvas.viewport.x -= adjustX * direction;
canvas.viewport.y -= adjustY * direction;
const action = direction === 1 ? 'OPENING' : 'CLOSING';
log.info(`FULLSCREEN ${action} - Viewport adjusted for centering:`, {
widthDiff, heightDiff, adjustX, adjustY,
viewport_after: { x: canvas.viewport.x, y: canvas.viewport.y, zoom: canvas.viewport.zoom }
});
}
// Store the adjustment
viewportAdjustment = { x: adjustX, y: adjustY };
// Apply the adjustment
canvas.viewport.x -= viewportAdjustment.x;
canvas.viewport.y -= viewportAdjustment.y;
};
/**
* Restores the viewport when exiting fullscreen mode.
*/
const adjustViewportOnClose = () => {
// Apply the stored adjustment in reverse
canvas.viewport.x += viewportAdjustment.x;
canvas.viewport.y += viewportAdjustment.y;
// Reset adjustment
viewportAdjustment = { x: 0, y: 0 };
};
const closeEditor = () => {
// Get fullscreen rect BEFORE removing from DOM
const fullscreenRect = backdrop?.querySelector('.painter-modal-content')?.getBoundingClientRect();
const currentRect = originalParent?.getBoundingClientRect();
log.info(`FULLSCREEN CLOSING - Window sizes:`, {
current: { width: currentRect?.width, height: currentRect?.height },
fullscreen: { width: fullscreenRect?.width, height: fullscreenRect?.height },
viewport_before: { x: canvas.viewport.x, y: canvas.viewport.y, zoom: canvas.viewport.zoom }
});
if (originalParent && backdrop) {
originalParent.appendChild(mainContainer);
document.body.removeChild(backdrop);
@@ -846,12 +885,7 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
document.removeEventListener('keydown', handleEscKey);
setTimeout(() => {
// Use the actual canvas container for centering calculation
const currentCanvasContainer = originalParent!.querySelector('.painterCanvasContainer.painter-container') as HTMLElement;
const fullscreenCanvasContainer = backdrop!.querySelector('.painterCanvasContainer.painter-container') as HTMLElement;
const currentRect = currentCanvasContainer.getBoundingClientRect();
const fullscreenRect = fullscreenCanvasContainer.getBoundingClientRect();
adjustViewportForCentering(currentRect, fullscreenRect, -1);
adjustViewportOnClose();
canvas.render();
if (node.onResize) {
node.onResize();
@@ -865,7 +899,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
e.preventDefault();
e.stopPropagation();
closeEditor();
log.info("Fullscreen editor closed via ESC key");
}
};
@@ -875,13 +908,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
return;
}
const originalRect = canvasContainer.getBoundingClientRect();
originalParent = mainContainer.parentElement;
if (!originalParent) {
log.error("Could not find original parent of the canvas container!");
return;
}
backdrop = $el("div.painter-modal-backdrop") as HTMLDivElement;
const modalContent = $el("div.painter-modal-content") as HTMLDivElement;
@@ -897,12 +931,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
document.addEventListener('keydown', handleEscKey);
setTimeout(() => {
// Use the actual canvas container for centering calculation
const originalCanvasContainer = originalParent!.querySelector('.painterCanvasContainer.painter-container') as HTMLElement;
const fullscreenCanvasContainer = modalContent.querySelector('.painterCanvasContainer.painter-container') as HTMLElement;
const originalRect = originalCanvasContainer.getBoundingClientRect();
const fullscreenRect = fullscreenCanvasContainer.getBoundingClientRect();
adjustViewportForCentering(originalRect, fullscreenRect, 1);
adjustViewportOnOpen(originalRect);
canvas.render();
if (node.onResize) {
node.onResize();

View File

@@ -1,54 +1,96 @@
.painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
background-color: #444;
border: 1px solid #555;
border-radius: 5px;
color: #e0e0e0;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.2s ease-in-out;
min-width: 80px;
text-align: center;
margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
background-color: #555;
border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
.painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
transform: translateY(1px);
background-color: #3a3a3a;
transform: translateY(0);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
}
.painter-button:disabled,
.painter-button:disabled:hover {
background: #555;
color: #888;
background-color: #3a3a3a;
color: #777;
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: #444;
border-color: #4a4a4a;
opacity: 0.6;
}
.painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
border-color: #2a4cb4;
background-color: #3a76d6;
border-color: #2a6ac4;
color: #fff;
text-shadow: none;
}
.painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
background-color: #4a86e4;
border-color: #3a76d6;
}
.painter-button.success {
background-color: #4a7c59;
border-color: #3a6c49;
color: #fff;
}
.painter-button.success:hover {
background-color: #5a8c69;
border-color: #4a7c59;
}
.painter-button.danger {
background-color: #c54747;
border-color: #a53737;
color: #fff;
}
.painter-button.danger:hover {
background-color: #d55757;
border-color: #c54747;
}
.painter-button.icon-button {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
font-size: 16px;
line-height: 30px; /* Match height */
display: flex;
align-items: center;
justify-content: center;
}
.painter-controls {
background: linear-gradient(to bottom, #404040, #383838);
border-bottom: 1px solid #2a2a2a;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 8px;
background-color: #2f2f2f;
border-bottom: 1px solid #202020;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
padding: 10px;
display: flex;
gap: 6px;
gap: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
@@ -56,57 +98,198 @@
.painter-slider-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 4px;
color: #fff;
font-size: 12px;
min-width: 100px;
}
.painter-slider-container input[type="range"] {
-webkit-appearance: none;
width: 80px;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
.painter-slider-container input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
.slider-value {
font-size: 11px;
color: #bbb;
margin-top: 2px;
min-height: 14px;
text-align: center;
}
.painter-button-group {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(0,0,0,0.2);
padding: 4px;
gap: 4px;
background-color: transparent;
padding: 0;
border-radius: 6px;
}
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
gap: 4px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
height: 30px; /* Match switch height */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
/* --- Clipboard Switch Modern --- */
.clipboard-switch {
position: relative;
width: 90px;
height: 30px;
box-sizing: border-box;
background-color: #444;
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background-color 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.clipboard-switch:hover {
background-color: #555;
border-color: #666;
}
.clipboard-switch:active {
background-color: #3a3a3a;
}
.clipboard-switch input[type="checkbox"] {
display: none;
}
.clipboard-switch .switch-track {
display: none;
}
.clipboard-switch .switch-knob {
position: absolute;
top: 2px;
left: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #5a5a5a;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 2;
}
.clipboard-switch .switch-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: #e0e0e0;
pointer-events: none;
z-index: 1;
transition: opacity 0.3s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace,
.clipboard-switch .switch-labels .text-system {
position: absolute;
transition: opacity 0.2s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
.clipboard-switch .switch-knob .switch-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.clipboard-switch .switch-knob .switch-icon img {
width: 100%;
height: 100%;
}
/* Checked state */
.clipboard-switch:has(input:checked) {
background-color: #3a76d6;
border-color: #2a6ac4;
}
.clipboard-switch:has(input:checked):hover {
background-color: #4a86e4;
border-color: #3a76d6;
}
.clipboard-switch input:checked ~ .switch-knob {
left: calc(100% - 26px);
background-color: #fff;
}
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
filter: invert(35%) sepia(100%) saturate(1500%) hue-rotate(200deg) brightness(90%) contrast(100%);
}
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
opacity: 1;
color: #fff;
padding-right: 20px;
}
.clipboard-switch input:checked ~ .switch-labels .text-system {
opacity: 0;
}
.painter-separator {
width: 1px;
height: 28px;
background-color: #2a2a2a;
height: 24px;
background-color: #444;
margin: 0 8px;
}

View File

@@ -18,11 +18,18 @@ export const LAYERFORGE_TOOLS = {
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings'
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
} as const;
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 7H7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H7V9h10v10z"/><path d="M19 3H9c-1.1 0-2 .9-2 2v2h2V5h10v10h-2v2h2c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`;
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,