Preserve preview harness, editor, email and task improvements

Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
This commit is contained in:
pewdiepie-archdaemon
2026-10-01 01:34:26 +00:00
parent 86f376ac3a
commit 2e8413a54a
170 changed files with 11010 additions and 1094 deletions
+49 -24
View File
@@ -41,27 +41,52 @@
* }} deps
*/
import { state } from './state.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireInpaintButtons({
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
getSelectedAIEndpoint, ensureActiveMaskLayer,
saveState, createLayer, composite, flatten, renderLayerPanel,
saveState, createLayer, composite, flatten, renderLayerPanel, renderLayer,
spinnerModule, uiModule,
}) {
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) {
// Pre-check: build the union mask the AI will receive and verify
// at least one pixel is painted.
const preMerged = buildMergedMaskCanvas();
if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
const pmCtx = preMerged.getContext('2d');
const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data;
let hasMask = false;
for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } }
if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
let preMerged = buildMergedMaskCanvas();
const hasPixels = canvas => canvas && canvas.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data.some((value, index) => index % 4 === 3 && value > 0);
let layerSource = null;
if (!hasPixels(preMerged)) {
const layer = state.layers.find(item => item.id === state.activeLayerId);
if (!layer || layer.kind === 'adjustment') {
uiModule?.showToast('Select an image layer to inpaint');
return;
}
const rendered = renderLayer?.(layer) || layer.canvas;
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
layerSource = document.createElement('canvas');
layerSource.width = state.imgWidth;
layerSource.height = state.imgHeight;
layerSource.getContext('2d').drawImage(rendered, offset.x, offset.y);
preMerged = document.createElement('canvas');
preMerged.width = state.imgWidth;
preMerged.height = state.imgHeight;
const ctx = preMerged.getContext('2d');
ctx.drawImage(layerSource, 0, 0);
// Preserve transparent cutouts; empty layers use their full bounds.
if (hasPixels(preMerged)) ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = '#fff';
ctx.fillRect(offset.x, offset.y, rendered.width, rendered.height);
ctx.globalCompositeOperation = 'source-over';
if (!hasPixels(preMerged)) {
uiModule?.showToast('The selected layer is outside the canvas');
return;
}
}
const btn = document.getElementById(btnId);
const btnLabel = labelId ? document.getElementById(labelId) : null;
btn.disabled = true;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Inpaint cancelled'));
if (btnLabel) btnLabel.textContent = busyLabel;
let runWp = null;
try {
@@ -110,7 +135,7 @@ export function wireInpaintButtons({
} catch (_) { /* overlay is decorative */ }
try {
// Flatten current image.
const flatCanvas = flatten();
const flatCanvas = layerSource || flatten();
// Dilate the user's brush mask before sending to the model.
// The AI fills a small buffer zone around the brush, so the
// post-gen Edge feather slider has AI content to fade INTO
@@ -123,7 +148,7 @@ export function wireInpaintButtons({
// This way, if the user built up the inpaint region across
// multiple masks, the final generation sees the combined
// region instead of just the currently-active mask.
const mergedMask = buildMergedMaskCanvas() || state.maskCanvas;
const mergedMask = preMerged;
const dilatedMask = dilateMask(mergedMask, padPx);
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
@@ -132,6 +157,7 @@ export function wireInpaintButtons({
baseSnap.height = state.imgHeight;
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
const res = await fetch('/api/image/inpaint', {
signal: operation.signal,
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify((() => {
@@ -152,8 +178,9 @@ export function wireInpaintButtons({
// unfeathered (AI image + hard mask) on the layer so the live
// Feather slider can re-derive the alpha on each input event
// without re-running the model.
const resultImg = new Image();
resultImg.onload = () => {
const resultImg = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
{
if (!state.editorOpen) return; // user closed mid-decode
try {
saveState('Inpaint result');
@@ -172,9 +199,9 @@ export function wireInpaintButtons({
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
const maskSnap = document.createElement('canvas');
maskSnap.width = state.maskCanvas.width;
maskSnap.height = state.maskCanvas.height;
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
maskSnap.width = mergedMask.width;
maskSnap.height = mergedMask.height;
maskSnap.getContext('2d').drawImage(mergedMask, 0, 0);
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
@@ -227,15 +254,11 @@ export function wireInpaintButtons({
console.error('[inpaint] render error', renderErr);
if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000);
}
};
resultImg.onerror = (e) => {
console.error('[inpaint] base64 decode failed', e);
if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000);
};
resultImg.src = 'data:image/png;base64,' + data.image;
}
} catch (e) {
if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
if (!operation.signal.aborted && uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
} finally {
operation.finish();
btn.disabled = false;
if (btnLabel) btnLabel.textContent = idleLabel;
if (runWp) { try { runWp.destroy(); } catch (_) {} }
@@ -263,7 +286,9 @@ export function wireInpaintButtons({
// SDXL inpaint pipelines literally try to draw the prompt, so we
// send a generic surroundings-matching prompt and crank strength.
document.getElementById('ge-inpaint-remove').addEventListener('click', async () => {
const sel = getSelectedAIEndpoint('inpaint');
let sel;
try { sel = getSelectedAIEndpoint('inpaint'); }
catch (error) { uiModule?.showToast(error.message); return; }
const ep = (sel.endpoint || '').toLowerCase();
const isOpenAI = ep.includes('api.openai.com');
let prompt, strength;
+15 -9
View File
@@ -27,11 +27,18 @@
import { state } from './state.js';
import { sortModelIds } from '../modelSort.js';
export function resolveInpaintModel(select) {
const options = Array.from(select?.options || []).filter(option =>
!option.disabled && option.value && option.value !== '__serve_cookbook__');
const explicit = options.find(option => option.value === select.value);
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
}
// Heuristic classifier on a model id + endpoint name. A model can be:
// - gen: text-to-image generation
// - inpaint: image+mask edit (inpaint / img2img)
// Some models do only one (e.g. dall-e-3 = gen-only, no edits API).
function modelCaps(modelId, endpointName, endpointType) {
export function modelCaps(modelId, endpointName, endpointType) {
const id = (modelId || '').toLowerCase();
const name = (endpointName || '').toLowerCase();
const type = (endpointType || '').toLowerCase();
@@ -41,7 +48,9 @@ function modelCaps(modelId, endpointName, endpointType) {
// OpenAI image family.
if (/dall-e-3/.test(id)) return { gen: true, inpaint: false };
if (/dall-e-2/.test(id)) return { gen: true, inpaint: true };
if (/gpt-image/.test(id)) return { gen: true, inpaint: true };
if (/(?:^|\/)(?:gpt-image[^/]*|gpt-[^/]*-image[^/]*|gemini-[^/]*image[^/]*|qwen-image[^/]*)$/.test(id)) {
return { gen: true, inpaint: true };
}
// Diffusion families — most generic SD/SDXL/Flux base models
// support both via diffusers.
if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) {
@@ -95,13 +104,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`);
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const endpoints = await res.json();
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
let firstGen = null;
let firstInpaint = null;
let selectedGen = null;
let selectedInpaint = null;
for (const ep of endpoints) {
@@ -137,10 +146,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
// Prefer dedicated inpaint/edit models for default selection.
if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) {
firstInpaint = value;
}
}
// Per-tool selectors get every img2img-capable entry. Both
// caps.inpaint AND caps.gen models work for harmonize /
@@ -165,7 +170,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if (aiInpaintSelect) {
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
}
// Append the "Serve a model in Cookbook…" sentinel at the
// bottom of every model dropdown.
@@ -221,7 +227,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
// Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no
// hint about what to do next.
const fallback = '<option value="">Auto</option><option value="" disabled>──────────</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
+45
View File
@@ -0,0 +1,45 @@
// Capture repeat clicks before tool handlers can start another request.
export function beginAIOperation(button, onCancel) {
const controller = new AbortController();
const title = button.title;
button.disabled = false;
button.title = 'Cancel running action';
button.setAttribute('aria-busy', 'true');
const cancel = event => {
event.preventDefault();
event.stopImmediatePropagation();
if (!controller.signal.aborted) {
controller.abort();
onCancel?.();
}
};
button.addEventListener('click', cancel, true);
return {
signal: controller.signal,
finish() {
button.removeEventListener('click', cancel, true);
button.title = title;
button.removeAttribute('aria-busy');
},
};
}
export function decodeAIImage(base64, signal) {
return new Promise((resolve, reject) => {
const image = new Image();
const cleanup = () => {
image.onload = image.onerror = null;
signal.removeEventListener('abort', abort);
};
const abort = () => {
cleanup();
image.src = '';
reject(new DOMException('Cancelled', 'AbortError'));
};
if (signal.aborted) { abort(); return; }
signal.addEventListener('abort', abort, { once: true });
image.onload = () => { cleanup(); resolve(image); };
image.onerror = () => { cleanup(); reject(new Error('Failed to decode result image')); };
image.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
});
}
+14 -14
View File
@@ -33,6 +33,7 @@
* @returns {(endpoint: string, extraPayload: object, layerName: string, btn: HTMLButtonElement, opts?: { busyLabel?: string }) => Promise<void>}
*/
import { state } from './state.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
const KNOWN_DEPS = ['realesrgan', 'rembg'];
@@ -45,7 +46,7 @@ export function createApplyImageTool({
return async function applyImageTool(endpoint, extraPayload, layerName, btn, opts) {
const origHTML = btn.innerHTML;
const origWidth = btn.offsetWidth; // lock width so the button doesn't jump
btn.disabled = true;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Cancelled'));
btn.classList.add('ge-btn-processing');
btn.style.minWidth = origWidth + 'px';
// Swap label for a "<verbing>…" text + whirlpool while the
@@ -67,18 +68,19 @@ export function createApplyImageTool({
// Tool-specific model picker — pulled from the per-tool select
// (harmonize/style) if available, otherwise the global
// fallback. Derived from the endpoint URL.
if (!extraPayload._endpoint) {
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
const sel = getSelectedAIEndpoint(type);
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
}
try {
if (!extraPayload._endpoint) {
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
const sel = getSelectedAIEndpoint(type);
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
}
const flatCanvas = flatten();
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const body = { image: imageB64, ...extraPayload };
const res = await fetch(endpoint, {
signal: operation.signal,
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -91,12 +93,8 @@ export function createApplyImageTool({
const data = await res.json();
if (data.error) throw new Error(data.error);
if (!data.image) throw new Error('No image returned');
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('Failed to decode result image'));
img.src = 'data:image/png;base64,' + data.image;
});
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
{
saveState();
@@ -109,6 +107,7 @@ export function createApplyImageTool({
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
}
} catch (e) {
if (operation.signal.aborted) return;
// Detect known failure modes and surface an action-toast.
const msg = (e?.message || '').toLowerCase();
const needsImg2Img = (
@@ -140,6 +139,7 @@ export function createApplyImageTool({
}
}
} finally {
operation.finish();
btn.disabled = false;
btn.classList.remove('ge-btn-processing');
try { btnSpinner?.destroy(); } catch {}
+26 -17
View File
@@ -31,6 +31,7 @@
* @returns {{ addEmptyLayer: () => void }}
*/
import { state } from './state.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireAIToolsMisc({
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
@@ -104,7 +105,7 @@ export function wireAIToolsMisc({
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-upscale-ai');
const origHTML = btn.innerHTML;
btn.disabled = true;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Upscale cancelled'));
let upWp = null;
try {
upWp = spinnerModule.createWhirlpool(14);
@@ -119,6 +120,7 @@ export function wireAIToolsMisc({
const flat = flatten();
const imageB64 = flat.toDataURL('image/png').split(',')[1];
const res = await fetch('/api/image/upscale-local', {
signal: operation.signal,
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageB64, scale: 2 }),
@@ -126,8 +128,9 @@ export function wireAIToolsMisc({
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = new Image();
img.onload = () => {
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
{
if (!state.editorOpen) return;
saveState();
const newW = img.width, newH = img.height;
@@ -144,17 +147,18 @@ export function wireAIToolsMisc({
composite();
renderLayerPanel();
uiModule.showToast(`AI upscaled to ${newW}×${newH}`);
};
img.src = 'data:image/png;base64,' + data.image;
}
} else {
throw new Error(data.error || 'No image returned');
}
} catch (e) {
uiModule.showToast('AI upscale failed: ' + e.message);
if (!operation.signal.aborted) uiModule.showToast('AI upscale failed: ' + e.message);
} finally {
operation.finish();
try { upWp?.destroy(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = origHTML;
}
try { upWp?.destroy(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = origHTML;
});
// ── Style transfer ──
@@ -166,7 +170,9 @@ export function wireAIToolsMisc({
const prompt = document.getElementById('ge-style-prompt').value.trim();
if (!prompt) { uiModule.showToast('Enter a style prompt'); return; }
const strength = parseInt(document.getElementById('ge-style-strength').value) / 100;
btn.disabled = true; btn.textContent = 'Applying...';
const originalHTML = btn.innerHTML;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Style transfer cancelled'));
btn.textContent = 'Applying...';
try {
const flat = flatten();
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
@@ -174,12 +180,13 @@ export function wireAIToolsMisc({
fd.append('image', blob, 'style.png');
fd.append('prompt', prompt);
fd.append('strength', String(strength));
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd });
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd, signal: operation.signal });
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = new Image();
img.onload = () => {
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
{
if (!state.editorOpen) return;
saveState();
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
@@ -189,15 +196,17 @@ export function wireAIToolsMisc({
composite();
renderLayerPanel();
uiModule.showToast('Style applied');
};
img.src = 'data:image/png;base64,' + data.image;
}
} else {
throw new Error(data.error || 'No image returned');
}
} catch (e) {
uiModule.showToast('Style transfer failed: ' + e.message);
if (!operation.signal.aborted) uiModule.showToast('Style transfer failed: ' + e.message);
} finally {
operation.finish();
btn.disabled = false;
btn.innerHTML = originalHTML;
}
btn.disabled = false; btn.textContent = 'Apply Style';
});
// ── Add empty layer (used by the layer-panel header button + the
+15 -3
View File
@@ -12,6 +12,13 @@
export function controlsHTML({ color, brushSize, wandTolerance }) {
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
return `
<div id="ge-pen-section" style="display:none;">
<div class="ge-section-title">Pen Selection</div>
<div class="ge-control-row ge-actions">
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-commit" title="Close path and make selection (Enter)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> Make selection</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
</div>
</div>
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
<div class="ge-layer-geometry-grid">
@@ -238,7 +245,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+I)">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Invert
</button>
@@ -289,7 +296,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Clear
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+I)">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Invert
</button>
@@ -612,6 +619,10 @@ export function layerPanelHTML() {
return `<div class="ge-layers-header">
<span class="ge-layers-grab"></span>
<span class="ge-layers-title">Layers</span>
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
</button>
@@ -627,7 +638,8 @@ export function layerPanelHTML() {
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-add-layer" title="Add empty layer">+ Add</button>
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
</div>
</div>
<div class="ge-layer-blend-row">
<label for="ge-layer-blend">Blend</label>
+2
View File
@@ -38,6 +38,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
{ id: 'pen', label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
{ sep: true },
@@ -60,6 +61,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
btn.dataset.tool = t.id;
btn.title = t.label + (t.key ? ` (${t.key})` : '');
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
// Heavy 4-point AI star marker for AI-backed tools — sits just to
// the left of the icon so the user can spot AI vs local tools at a
// glance now that the "AI Tools" separator is gone.
+22 -23
View File
@@ -1,5 +1,5 @@
/**
* Build the editor's top bar (undo/redo/history, zoom group, Image
* Build the editor's top bar (undo/redo/history, Image
* menu, Filter menu, Selection-edge menu, Shortcuts, Import, Save).
*
* Pure DOM — no module state, no event listeners. All wiring is done
@@ -30,28 +30,8 @@ export function buildTopbar() {
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
<span class="ge-stacked-label">BEFORE</span>
</button>
<span class="ge-topbar-sep"></span>
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out">&minus;</button>
<span class="ge-zoom-stack">
<span class="ge-zoom-glyph">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<span class="ge-zoom-label">100%</span>
</span>
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in">+</button>
<span class="ge-topbar-sep"></span>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-fit" title="Fit to view" aria-pressed="false">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg></span>
<span class="ge-stacked-label">FIT</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Actual size" aria-pressed="false">
<span class="ge-stacked-glyph">1:1</span>
<span class="ge-stacked-label">SCALE</span>
</button>
<span class="ge-topbar-sep"></span>
</div>
<div class="ge-topbar-right">
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
<div class="ge-view-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
@@ -73,7 +53,7 @@ export function buildTopbar() {
<span class="ge-stacked-label">IMAGE</span>
</button>
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
<button class="dropdown-item-compact" data-image-action="fill"><span>Fill selection / mask</span></button>
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
<button class="dropdown-item-compact" data-image-action="canvas-size">
<span class="dropdown-icon">⤢</span>
<span>Canvas Size...</span>
@@ -110,7 +90,7 @@ export function buildTopbar() {
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+I</span></button>
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
<div class="dropdown-section-divider"></div>
@@ -215,3 +195,22 @@ export function buildTopbar() {
`;
return topBar;
}
export function buildZoomFooter() {
const footer = document.createElement('div');
footer.className = 'ge-editor-footer';
footer.innerHTML = `
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">&minus;</button>
<span class="ge-zoom-stack" aria-live="off">
<span class="ge-zoom-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="ge-zoom-label">100%</span>
</span>
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Switch to actual size" aria-label="Fit view; switch to actual size" aria-pressed="false">
<span class="ge-stacked-glyph">1:1</span><span class="ge-stacked-label">FIT</span>
</button>
</div>`;
return footer;
}
+15 -16
View File
@@ -40,7 +40,10 @@ export function wireClipboardAndDrop({
const { signal } = clipboardBindings;
// ── Paste ──
window.addEventListener('paste', (e) => {
if (!state.editorOpen || state.container !== container || e.defaultPrevented) return;
if (!state.editorOpen || state.container !== container) return;
// Editable fields keep native paste. The Clipboard button focuses the
// canvas container before asking for a keyboard paste when read() is
// unavailable on an insecure origin.
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
@@ -65,30 +68,26 @@ export function wireClipboardAndDrop({
uiModule.showToast('Pasted as new layer');
}
// Check internal clipboard first (from Ctrl+C lasso/wand).
if (state.internalClipboard) {
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
if (imageItem) {
e.preventDefault();
e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
return;
}
// Fall back to system clipboard.
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (!item.type.startsWith('image/')) continue;
e.preventDefault();
e.stopImmediatePropagation();
const blob = item.getAsFile();
const blob = imageItem.getAsFile();
if (!blob) return;
const url = URL.createObjectURL(blob);
const img = new Image();
// External clipboard images follow the same source-backed import path
// as files, gallery images, and drops. Internal selection clipboard
// content is handled above as an identity placed layer.
img.onload = () => { handleImportedImage(img, 'Pasted image'); URL.revokeObjectURL(url); };
img.onerror = () => { URL.revokeObjectURL(url); uiModule?.showToast('Failed to load clipboard image'); };
img.src = url;
break;
return;
}
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
e.preventDefault();
e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
}
}, { capture: true, signal });
+6 -7
View File
@@ -1,4 +1,5 @@
/* Worker implementation for retained-effect rasterization. */
import { LAYER_STYLES, renderLayerStyle, renderDropShadow } from './layer-styles.js';
function copyCanvas(source) {
const out = new OffscreenCanvas(source.width, source.height);
@@ -56,7 +57,9 @@ function renderEffects(source, effects, masks) {
if (effect.visible === false || effect.opacity <= 0) continue;
const next = new OffscreenCanvas(current.width, current.height);
const ctx = next.getContext('2d');
if (effect.type === 'gaussian-blur') {
if (LAYER_STYLES[effect.type]) {
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
} else if (effect.type === 'gaussian-blur') {
ctx.filter = `blur(${effect.params.radius}px)`;
ctx.drawImage(current, 0, 0);
ctx.filter = 'none';
@@ -69,12 +72,7 @@ function renderEffects(source, effects, masks) {
ctx.fillStyle = effect.params.color;
ctx.fillRect(0, 0, next.width, next.height);
} else if (effect.type === 'drop-shadow') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.blur;
ctx.shadowOffsetX = effect.params.x;
ctx.shadowOffsetY = effect.params.y;
ctx.drawImage(current, 0, 0);
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
@@ -98,6 +96,7 @@ function renderEffects(source, effects, masks) {
const contribution = maskedContribution(next, masks[index]);
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
+18 -8
View File
@@ -1,6 +1,16 @@
/** Retained, non-destructive layer effects. */
import { LAYER_STYLES, styleParams, renderLayerStyle, renderDropShadow } from './layer-styles.js';
export function effectsWithPreview(owner) {
const effects = owner.effects || [];
const preview = owner._effectPreview;
if (!preview) return effects;
const index = preview.id ? effects.findIndex(effect => effect.id === preview.id) : -1;
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
}
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
export const EFFECT_PRESETS = {
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
@@ -28,6 +38,7 @@ function normalizeEffectMask(mask) {
}
export function defaultEffectParams(type) {
if (LAYER_STYLES[type]) return styleParams(type);
if (type === 'gaussian-blur') return { radius: 6 };
if (type === 'sharpen') return { amount: 0.5 };
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
@@ -52,6 +63,7 @@ export function normalizeEffect(effect) {
const params = effect?.params && typeof effect.params === 'object'
? { ...defaults, ...clone(effect.params) }
: defaults;
if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params));
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
if (type === 'color-overlay') {
@@ -98,6 +110,7 @@ export function normalizeEffect(effect) {
}
export function effectLabel(type) {
if (LAYER_STYLES[type]) return LAYER_STYLES[type].label;
return {
'gaussian-blur': 'Gaussian Blur',
sharpen: 'Sharpen',
@@ -189,7 +202,9 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
next.width = current.width;
next.height = current.height;
const ctx = next.getContext('2d');
if (effect.type === 'gaussian-blur') {
if (LAYER_STYLES[effect.type]) {
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
} else if (effect.type === 'gaussian-blur') {
ctx.filter = `blur(${effect.params.radius}px)`;
ctx.drawImage(current, 0, 0);
ctx.filter = 'none';
@@ -205,13 +220,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
ctx.fillRect(0, 0, next.width, next.height);
ctx.globalAlpha = 1;
} else if (effect.type === 'drop-shadow') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.blur;
ctx.shadowOffsetX = effect.params.x;
ctx.shadowOffsetY = effect.params.y;
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = 1;
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
@@ -232,6 +241,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
: next;
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
+50 -2
View File
@@ -7,6 +7,53 @@ function imageDataBytes(imageData) {
return Number(imageData?.data?.byteLength || imageData?.data?.length || 0);
}
function pixelSlots(snapshot) {
const slots = [];
const add = (object, key = 'imageData') => { if (object?.[key]) slots.push([object, key]); };
add(snapshot?.wand); add(snapshot?.lastSelection);
for (const item of snapshot?.savedSelections || []) add(item);
for (const group of snapshot?.layerGroups || []) {
for (const mask of group.masks || []) add(mask);
for (const effect of group.effects || []) add(effect.mask);
}
for (const layer of snapshot?.layers || []) {
add(layer); add(layer.placed, 'sourceImageData');
for (const mask of layer.masks || []) add(mask);
for (const effect of layer.effects || []) add(effect.mask);
}
return slots;
}
export function shareSnapshotPixels(snapshot, previous) {
if (!previous) return snapshot;
const candidates = pixelSlots(previous).map(([object, key]) => object[key]);
for (const [object, key] of pixelSlots(snapshot)) {
const image = object[key];
const match = candidates.find(other => {
if (image === other) return true;
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
for (let i = 0; i < image.data.length; i++) if (image.data[i] !== other.data[i]) return false;
return true;
});
if (match) object[key] = match;
}
return snapshot;
}
export function historyByteSize(stack) {
const buffers = new Set();
let bytes = 0;
for (const snapshot of stack) {
const slots = pixelSlots(snapshot);
if (!slots.length) { bytes += snapshotByteSize(snapshot); continue; }
for (const [object, key] of slots) {
const image = object[key], buffer = image.data.buffer || image.data;
if (!buffers.has(buffer)) { buffers.add(buffer); bytes += buffer.byteLength || imageDataBytes(image); }
}
}
return bytes;
}
export function snapshotByteSize(snapshot) {
if (!snapshot) return 0;
if (Number.isFinite(snapshot._bytes)) return snapshot._bytes;
@@ -30,10 +77,11 @@ export function trimHistoryStack(
maxBytes = MAX_HISTORY_BYTES,
) {
while (stack.length > maxEntries) stack.shift();
let bytes = stack.reduce((total, snapshot) => total + snapshotByteSize(snapshot), 0);
let bytes = historyByteSize(stack);
// Keep the newest state even when one snapshot alone exceeds the budget.
while (stack.length > 1 && bytes > maxBytes) {
bytes -= snapshotByteSize(stack.shift());
stack.shift();
bytes = historyByteSize(stack);
}
return bytes;
}
+9 -3
View File
@@ -12,7 +12,7 @@
* Ctrl+S save (Shift = save as / export to gallery)
* Ctrl+Shift+T open resize popup
* Ctrl+Alt+T start free transform
* Ctrl+Alt+I invert wand / lasso selection
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
* Ctrl+Alt+J new empty layer
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
* Ctrl+Alt+G create/release clipping mask
@@ -74,7 +74,7 @@ export function wireKeyboardShortcuts(deps) {
setTemporaryPan,
nudgeActiveLayer, endLayerNudge,
toggleQuickMask, nudgeSelection,
deselectSelection,
deselectSelection, fillSelection,
} = deps;
const isTypingTarget = (target) => target && (
@@ -94,6 +94,12 @@ export function wireKeyboardShortcuts(deps) {
if (e.target?.closest?.('#styled-confirm-overlay')) return;
// Fields and text layers own native editing, including undo and clipboard.
if (isTypingTarget(e.target)) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
if (!e.repeat) fillSelection?.();
return;
}
if (e.code === 'Space' && !isTypingTarget(e.target)) {
e.preventDefault();
setTemporaryPan?.(true);
@@ -170,7 +176,7 @@ export function wireKeyboardShortcuts(deps) {
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
// Ctrl+Alt+I — invert current selection. Uses e.code so
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
// don't break the match.
if (e.altKey && e.code === 'KeyI') {
+67 -32
View File
@@ -99,7 +99,7 @@ export function createLayerPanelRenderer(deps) {
const {
composite, saveState, showLayerThumb, hideLayerThumb,
loadLayerAlphaAsSelection, loadMaskAsSelection, getDocumentSelection,
openFxPopup, editAdjLayer, editRetainedEffect, addEffectMask, rasterizeEffects,
openFxPopup, openLayerStyles, editAdjLayer, editRetainedEffect, addEffectMask, rasterizeEffects,
createLayer, renderLayer, replacePlacedLayer, rasterizePlacedLayer,
lassoToMask, wandToMask, getActiveMaskLayer,
syncFxPanelToActiveLayerIfPresent, onSelectLayer,
@@ -227,15 +227,39 @@ export function createLayerPanelRenderer(deps) {
const summary = document.createElement('summary');
summary.title = `${label} properties`;
summary.setAttribute('aria-label', summary.title);
summary.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"></path><circle cx="9" cy="6" r="2"></circle><circle cx="15" cy="12" r="2"></circle><circle cx="11" cy="18" r="2"></circle></svg>';
summary.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8h4m4 0h10M3 16h10m4 0h4M7 5v6m10 2v6"/></svg>';
const panel = document.createElement('div');
panel.className = 'ge-mask-properties-panel';
details.addEventListener('toggle', () => {
if (!details.open) return;
panel.setAttribute('popover', 'manual');
let positionFrame = 0;
const positionPanel = () => {
if (!details.open || !details.isConnected) {
if (panel.matches(':popover-open')) panel.hidePopover();
return;
}
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0;
const top = viewport?.offsetTop || 0;
const width = viewport?.width || window.innerWidth;
const height = viewport?.height || window.innerHeight;
panel.style.maxWidth = `${Math.max(0, width - 12)}px`;
panel.style.maxHeight = `${Math.max(0, height - 12)}px`;
const rect = summary.getBoundingClientRect();
const width = 190;
panel.style.left = `${Math.max(6, Math.min(window.innerWidth - width - 6, rect.right - width))}px`;
panel.style.top = `${Math.min(window.innerHeight - 150, rect.bottom + 4)}px`;
const bounds = panel.getBoundingClientRect();
const y = rect.bottom + 4 + bounds.height <= top + height - 6
? rect.bottom + 4 : rect.top - bounds.height - 4;
panel.style.left = `${Math.max(left + 6, Math.min(left + width - bounds.width - 6, rect.right - bounds.width))}px`;
panel.style.top = `${Math.max(top + 6, Math.min(top + height - bounds.height - 6, y))}px`;
positionFrame = requestAnimationFrame(positionPanel);
};
details.addEventListener('toggle', () => {
cancelAnimationFrame(positionFrame);
if (!details.open) {
if (panel.matches(':popover-open')) panel.hidePopover();
return;
}
panel.showPopover();
positionPanel();
});
const makeRange = (className, title, min, max, step, value, update, historyLabel) => {
const row = document.createElement('label');
@@ -1034,12 +1058,7 @@ export function createLayerPanelRenderer(deps) {
const list = document.getElementById('ge-layers-list');
if (!list) return;
const sharedTools = document.getElementById('ge-layer-tools');
if (sharedTools) {
// Controls are rebuilt with each row, so clear the previous active
// row's cluster before the list is rendered again.
sharedTools.innerHTML = '';
sharedTools.hidden = true;
}
if (sharedTools) sharedTools.hidden = true;
const blendSelect = document.getElementById('ge-layer-blend');
normalizeLayerSelection(state);
wireSelectionActions();
@@ -1148,6 +1167,10 @@ export function createLayerPanelRenderer(deps) {
loadLayerAlphaAsSelection(layer);
return;
}
if (!e.shiftKey && !e.ctrlKey && !e.metaKey &&
state.activeLayerId === layer.id && !state.activeGroupId &&
!layer.activeMaskId && state.selectedLayerIds.length === 1 &&
state.selectedLayerIds[0] === layer.id) return;
if (e.shiftKey) selectLayerRange(state, layer.id);
else if (e.ctrlKey || e.metaKey) toggleLayerSelection(state, layer.id);
else selectOnlyLayer(state, layer.id);
@@ -1157,6 +1180,7 @@ export function createLayerPanelRenderer(deps) {
state.maskCanvas = null;
state.maskCtx = null;
syncSelectionUi();
document.querySelectorAll('.ge-layer-group-row.active').forEach(row => row.classList.remove('active'));
onSelectLayer?.(active);
composite();
// Preserve the name element across the first click of a double-click
@@ -1195,6 +1219,21 @@ export function createLayerPanelRenderer(deps) {
// from the raster canvas. Use the normal renderer when available so
// their layer previews match what the document actually displays.
const thumb = createInlineThumbnail(() => renderLayer?.(layer) || layer.canvas, `${layer.name} preview`);
thumb.title = `${layer.name} preview (Ctrl-click to select pixels)`;
thumb.addEventListener('click', (event) => {
if (!(event.ctrlKey || event.metaKey) || layer.kind === 'adjustment') return;
event.preventDefault();
event.stopPropagation();
selectOnlyLayer(state, layer.id);
layer.activeMaskId = null;
state.maskInspectMode = false;
state.maskCanvas = null;
state.maskCtx = null;
syncSelectionUi();
onSelectLayer?.(layer);
loadLayerAlphaAsSelection(layer);
render();
});
item.appendChild(thumb);
const nameEl = document.createElement('span');
@@ -1203,7 +1242,7 @@ export function createLayerPanelRenderer(deps) {
const marker = document.createElement('span');
marker.className = 'ge-layer-placed-marker';
marker.title = 'Placed image';
marker.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>';
marker.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:relative;top:2px"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>';
nameEl.appendChild(marker);
}
if (layer.kind === 'text' || layer.kind === 'shape') {
@@ -1230,7 +1269,6 @@ export function createLayerPanelRenderer(deps) {
nameEl.appendChild(document.createTextNode(layer.name + (layer.kind !== 'adjustment' && isLayerEmpty(layer) ? ' (empty)' : '')));
// Keep the name node stable across the first click of a double-click
// so inline renaming can receive the browser's dblclick event.
nameEl.addEventListener('click', event => event.stopPropagation());
nameEl.addEventListener('dblclick', event => {
event.preventDefault();
event.stopPropagation();
@@ -1373,6 +1411,18 @@ export function createLayerPanelRenderer(deps) {
openLayerFx(e);
});
controls.appendChild(fxBtn);
const stylesButton = document.createElement('button');
stylesButton.type = 'button';
stylesButton.className = 'ge-layer-btn ge-layer-style-btn';
stylesButton.textContent = 'fx';
stylesButton.title = 'Layer styles';
stylesButton.setAttribute('aria-label', 'Layer styles');
stylesButton.setAttribute('aria-haspopup', 'menu');
stylesButton.setAttribute('aria-expanded', 'false');
stylesButton.addEventListener('click', event => {
event.stopPropagation(); openLayerStyles?.(layer, stylesButton);
});
if (layer.kind !== 'adjustment') controls.appendChild(stylesButton);
// Duplicate — clones pixels + offset + opacity + masks + adjLayers
// + visibility; inserts above the original; new copy becomes
@@ -1618,8 +1668,9 @@ export function createLayerPanelRenderer(deps) {
if (state.layers.length > 1) {
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer';
delBtn.setAttribute('aria-label', delBtn.title);
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteLayers(rowTargets(layer));
@@ -2045,22 +2096,6 @@ export function createLayerPanelRenderer(deps) {
}
}
// Keep one action strip for the active layer/group. The buttons retain
// their row-specific closures, but the UI no longer repeats them on
// every layer row.
if (sharedTools) {
const activeRow = selectedGroup
? [...list.children].find(row => row.dataset.groupId === selectedGroup.id)
: selectedLayer
? [...list.children].find(row => row.dataset.layerId === selectedLayer.id)
: null;
const controls = activeRow?.querySelector(':scope > .ge-layer-controls');
if (controls) {
sharedTools.appendChild(controls);
sharedTools.hidden = false;
}
}
// Wire the shared dragSort module — limit drag-init to the grip
// handle so row body clicks still activate. Called every render
// because `enable()` cleans up the previous instance keyed on
+43
View File
@@ -0,0 +1,43 @@
import { LAYER_STYLES } from './layer-styles.js';
const items = [
['bevel-emboss', 'Bevel & Emboss'], ['stroke', 'Stroke'],
['inner-shadow', 'Inner Shadow'], ['inner-glow', 'Inner Glow'], ['satin', 'Satin'],
['color-overlay', 'Color Overlay'], ['gradient-overlay', 'Gradient Overlay'],
['pattern-overlay', 'Pattern Overlay'], ['outer-glow', 'Outer Glow'], ['drop-shadow', 'Drop Shadow'],
];
let menu = null, activeAnchor = null;
export function openLayerStyleMenu(anchor, onSelect) {
const wasOpen = activeAnchor === anchor;
menu?.remove(); menu = null;
activeAnchor?.setAttribute('aria-expanded', 'false'); activeAnchor = null;
if (wasOpen) return;
const popup = document.createElement('div');
popup.className = 'dropdown session-dropdown-menu ge-layer-style-menu';
popup.setAttribute('popover', 'auto'); popup.setAttribute('role', 'menu');
popup.setAttribute('aria-label', 'Layer styles');
for (const [type, label] of items) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'dropdown-item-compact';
button.setAttribute('role', 'menuitem'); button.textContent = LAYER_STYLES[type]?.label || label;
button.addEventListener('click', event => {
event.stopPropagation(); popup.hidePopover(); onSelect(type);
});
popup.append(button);
}
popup.addEventListener('toggle', () => {
if (!popup.matches(':popover-open')) {
if (menu === popup) { menu = null; activeAnchor = null; }
anchor.setAttribute('aria-expanded', 'false'); popup.remove();
}
});
document.body.append(popup); menu = popup; activeAnchor = anchor;
popup.showPopover(); anchor.setAttribute('aria-expanded', 'true');
const rect = anchor.getBoundingClientRect();
popup.style.maxHeight = `${Math.max(40, Math.max(rect.top - 10, innerHeight - rect.bottom - 10))}px`;
const bounds = popup.getBoundingClientRect();
popup.style.left = `${Math.max(6, Math.min(innerWidth - bounds.width - 6, rect.right - bounds.width))}px`;
const top = rect.bottom + bounds.height + 10 <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4;
popup.style.top = `${Math.max(6, Math.min(innerHeight - bounds.height - 6, top))}px`;
popup.querySelector('button')?.focus();
}
+114
View File
@@ -0,0 +1,114 @@
/** Layer-style definitions and canvas rendering shared with the effects worker. */
const color = (key, label, value) => ({ key, label, type: 'color', value });
const number = (key, label, value, min, max, suffix = 'px') => ({ key, label, value, min, max, step: 1, suffix });
const opacity = () => number('opacity', 'Opacity', 65, 0, 100, '%');
const size = () => number('size', 'Size', 8, 0, 100);
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
export const LAYER_STYLES = {
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
'pattern-overlay': { label: 'Pattern Overlay', controls: [color('color', 'Color', '#ffffff'), color('endColor', 'Background', '#222222'), opacity(), number('scale', 'Tile size', 16, 2, 128)] },
'outer-glow': { label: 'Outer Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
};
export function styleParams(type, values = {}) {
return Object.fromEntries(LAYER_STYLES[type].controls.map(control => {
const raw = values[control.key] ?? control.value;
const value = control.type === 'color'
? (/^#[0-9a-f]{6}$/i.test(raw) ? raw : control.value)
: Math.max(control.min, Math.min(control.max, Number.isFinite(Number(raw)) ? Number(raw) : control.value));
return [control.key, value];
}));
}
function surface(width, height) {
if (typeof document === 'undefined') return new OffscreenCanvas(width, height);
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
return canvas;
}
export function renderDropShadow(source, params) {
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
// this silhouette; compositing the source again thickens translucent edges.
const silhouette = surface(source.width, source.height);
const sc = silhouette.getContext('2d');
sc.drawImage(source, 0, 0);
sc.globalCompositeOperation = 'source-in';
sc.fillStyle = params.color;
sc.fillRect(0, 0, source.width, source.height);
const shadow = surface(source.width, source.height), ctx = shadow.getContext('2d');
ctx.globalAlpha = params.opacity;
ctx.filter = `blur(${params.blur / 2}px)`;
ctx.drawImage(silhouette, params.x, params.y);
return shadow;
}
// Produce only the effect's contribution; the caller composites it over source.
export function renderLayerStyle(source, type, values) {
const p = styleParams(type, values), w = source.width, h = source.height;
const out = surface(w, h), ctx = out.getContext('2d');
const rad = (p.angle || 0) * Math.PI / 180;
const dx = Math.cos(rad) * (p.distance || 0), dy = -Math.sin(rad) * (p.distance || 0);
const clip = () => {
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(source, 0, 0);
ctx.globalCompositeOperation = 'source-over';
};
if (type === 'gradient-overlay' || type === 'pattern-overlay') {
if (type === 'gradient-overlay') {
const length = (Math.abs(Math.cos(rad)) * w + Math.abs(Math.sin(rad)) * h) * p.scale / 200;
const gradient = ctx.createLinearGradient(w / 2 - Math.cos(rad) * length, h / 2 + Math.sin(rad) * length, w / 2 + Math.cos(rad) * length, h / 2 - Math.sin(rad) * length);
gradient.addColorStop(0, p.color); gradient.addColorStop(1, p.endColor);
ctx.fillStyle = gradient;
} else {
const tile = surface(p.scale * 2, p.scale * 2), tc = tile.getContext('2d');
tc.fillStyle = p.endColor; tc.fillRect(0, 0, tile.width, tile.height);
tc.fillStyle = p.color; tc.fillRect(0, 0, p.scale, p.scale); tc.fillRect(p.scale, p.scale, p.scale, p.scale);
ctx.fillStyle = ctx.createPattern(tile, 'repeat');
}
ctx.fillRect(0, 0, w, h); clip();
} else if (type === 'outer-glow') {
ctx.filter = `blur(${p.size}px)`;
ctx.drawImage(source, 0, 0); ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'destination-out'; ctx.drawImage(source, 0, 0);
} else if (type === 'inner-shadow' || type === 'inner-glow') {
// Pad the inverse alpha so edges touching the canvas still cast inward.
const pad = Math.ceil(p.size * 3 + (p.distance || 0) + 2);
const inverse = surface(w + pad * 2, h + pad * 2), ic = inverse.getContext('2d');
ic.fillStyle = p.color; ic.fillRect(0, 0, inverse.width, inverse.height);
ic.globalCompositeOperation = 'destination-out'; ic.drawImage(source, pad, pad);
ctx.filter = `blur(${p.size}px)`;
ctx.drawImage(inverse, -pad + dx, -pad + dy); ctx.filter = 'none'; clip();
} else if (type === 'satin') {
const a = surface(w, h), ac = a.getContext('2d');
ac.filter = `blur(${p.size}px)`; ac.drawImage(source, dx, dy);
ac.globalCompositeOperation = 'xor'; ac.drawImage(source, -dx, -dy); ac.filter = 'none';
ctx.drawImage(a, 0, 0);
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h); clip();
} else if (type === 'bevel-emboss') {
const blurred = surface(w, h), bc = blurred.getContext('2d');
bc.filter = `blur(${Math.max(0.5, p.size / 2)}px)`; bc.drawImage(source, 0, 0);
const alpha = bc.getImageData(0, 0, w, h).data;
const original = source.getContext('2d').getImageData(0, 0, w, h).data;
const pixels = ctx.createImageData(w, h), step = Math.max(1, Math.round(p.size / 2));
const sample = (x, y) => x < 0 || x >= w || y < 0 || y >= h ? 0 : alpha[(y * w + x) * 4 + 3] / 255;
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
const light = rgb(p.highlight), dark = rgb(p.color);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const at = (y * w + x) * 4;
const slope = ((sample(x + step, y) - sample(x - step, y)) * Math.cos(rad)
- (sample(x, y + step) - sample(x, y - step)) * Math.sin(rad)) * p.depth / 100;
pixels.data.set(slope > 0 ? light : dark, at);
pixels.data[at + 3] = Math.min(1, Math.abs(slope)) * original[at + 3];
}
ctx.putImageData(pixels, 0, 0);
}
const faded = surface(w, h), fc = faded.getContext('2d');
fc.globalAlpha = p.opacity / 100; fc.drawImage(out, 0, 0);
return faded;
}
+1 -1
View File
@@ -3,5 +3,5 @@ export const TOOL_SHORTCUTS = Object.freeze({
move: 'V', hand: 'H', crop: 'C', text: 'T', shape: 'U',
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
marquee: 'M', lasso: 'L', wand: 'W',
marquee: 'M', lasso: 'L', wand: 'W', pen: 'P',
});
+94
View File
@@ -0,0 +1,94 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi }) {
let points = [], dragging = false, owner = null, canvas = null, mode = 'replace';
const current = () => canvas === state.mainCanvas && state.tool === 'pen' && owner === activeLayer();
function cancel() {
const hadPath = points.length > 0;
points = []; dragging = false; owner = canvas = null;
if (hadPath) composite();
return hadPath;
}
function trace(ctx, closed) {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length + (closed ? 1 : 0); i++) {
const a = points[i - 1], b = points[i % points.length];
ctx.bezierCurveTo(a.x + a.dx, a.y + a.dy, b.x - b.dx, b.y - b.dy, b.x, b.y);
}
if (closed) ctx.closePath();
}
function commit() {
if (!current() || points.length < 3) return false;
const mask = document.createElement('canvas');
mask.width = state.imgWidth; mask.height = state.imgHeight;
const ctx = mask.getContext('2d');
trace(ctx, true);
ctx.fillStyle = '#fff'; ctx.fill();
saveState('Pen selection');
commitSelectionMask(mask, owner, mode, 'pen', { x: 0, y: 0 });
state.wandMaskVisible = true;
state.wandLastSeed = null;
state.lassoPoints = []; state.lassoActive = false;
cancel(); syncSelectionUi();
return true;
}
return {
cancel, commit,
begin(event) {
if (points.length && !current()) cancel();
if (!activeLayer()) return;
event.preventDefault();
const p = canvasCoords(event, state.mainCanvas);
if (points.length >= 3 && Math.hypot(p.x - points[0].x, p.y - points[0].y) * state.zoom < 9) {
commit(); return;
}
if (!points.length) {
owner = activeLayer(); canvas = state.mainCanvas;
mode = selectionModeForEvent(event, state.wandMode || 'replace');
}
points.push({ ...p, dx: 0, dy: 0 }); dragging = true;
composite();
},
drag(event) {
if (!dragging || !current()) return;
event.preventDefault();
const p = canvasCoords(event, state.mainCanvas), anchor = points.at(-1);
anchor.dx = p.x - anchor.x; anchor.dy = p.y - anchor.y;
composite();
},
end() { dragging = false; },
key(event) {
if (!points.length || !current()) return false;
if (event.key === 'Escape') cancel();
else if (event.key === 'Enter') commit();
else if (event.key === 'Backspace' || event.key === 'Delete' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z')) {
points.pop(); dragging = false; composite();
} else return false;
event.preventDefault(); event.stopImmediatePropagation(); return true;
},
draw() {
if (!points.length || !current()) return;
const ctx = state.mainCtx, unit = 1 / state.zoom;
ctx.save();
trace(ctx, false);
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
for (const p of points) {
if (p.dx || p.dy) {
ctx.beginPath(); ctx.moveTo(p.x - p.dx, p.y - p.dy); ctx.lineTo(p.x + p.dx, p.y + p.dy); ctx.stroke();
for (const sign of [-1, 1]) {
ctx.beginPath(); ctx.arc(p.x + sign * p.dx, p.y + sign * p.dy, 2.5 * unit, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
}
}
ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
ctx.strokeStyle = '#000'; ctx.strokeRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
ctx.strokeStyle = '#fff';
}
ctx.restore();
},
};
}
+9 -1
View File
@@ -99,6 +99,14 @@ export function wireImport({ container, saveState, createLayer, composite, rende
document.getElementById('ge-import-file')?.addEventListener('click', () => importFileInput.click());
document.getElementById('ge-import-paste')?.addEventListener('click', async () => {
const pasteFromKeyboard = () => {
document.getElementById('gallery-editor-container')?.focus();
uiModule?.showToast('Press Ctrl+V to paste the image');
};
if (!navigator.clipboard?.read) {
pasteFromKeyboard();
return;
}
try {
const clipItems = await navigator.clipboard.read();
let blob = null;
@@ -113,7 +121,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
img.onerror = () => { URL.revokeObjectURL(url); if (uiModule) uiModule.showToast('Failed to load clipboard image'); };
img.src = url;
} catch (e) {
if (uiModule) uiModule.showToast('Clipboard access denied or no image available');
pasteFromKeyboard();
}
});
+6 -3
View File
@@ -7,7 +7,7 @@
* #ge-compare-btn
* #ge-save-menu-btn + #ge-save-menu (Save / Save as / Download /
* Save project / Load project)
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-fit / #ge-zoom-100
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-100 (Fit / 1:1 toggle)
* #ge-export-gallery / #ge-download
* #ge-save-project / #ge-load-project
* #ge-edge-menu-btn + #ge-edge-menu (Width input + Feather / Delete
@@ -122,8 +122,11 @@ export function wireTopbar(deps) {
}
// Zoom buttons.
document.getElementById('ge-zoom-fit')?.addEventListener('click', fitZoom);
document.getElementById('ge-zoom-100')?.addEventListener('click', () => { state.zoom = 1; applyZoom(); });
document.getElementById('ge-zoom-100')?.addEventListener('click', () => {
if (state.zoomViewMode === 'scale' ||
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
});
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });