mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 23:02:23 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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; });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+15
-3
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">−</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">−</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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+18
-8
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user