Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release

# Conflicts:
#	routes/chat_routes.py
#	routes/session_routes.py
#	src/agent_loop.py
#	src/agent_tools/filesystem_tools.py
#	src/teacher_escalation.py
#	src/tool_capabilities.py
#	src/tool_execution.py
#	tests/test_mcp_add_server_args_validation.py
#	tests/test_token_cache_atomic_swap.py
This commit is contained in:
Alexandre Teixeira
2026-10-05 15:59:59 +01:00
1395 changed files with 360455 additions and 105938 deletions
+211
View File
@@ -0,0 +1,211 @@
/** First-class adjustment-layer rendering and retained metadata helpers. */
import { applyAdjustment } from './fx/pixel-pass.js';
import { defaultAdjParams } from './layer-helpers.js';
import { renderWithLayerMasks } from './composite-helpers.js';
const TYPES = new Set([
'brightness-contrast',
'exposure',
'white-balance',
'hue-saturation',
'vibrance',
'black-white',
'shadows-highlights',
'levels',
'curves',
'color-balance',
'selective-color',
'gradient-map',
]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
export function normalizeAdjustmentData(value) {
const type = TYPES.has(value?.type) ? value.type : 'levels';
const defaults = defaultAdjParams(type);
const params = value?.params && typeof value.params === 'object'
? { ...clone(defaults), ...clone(value.params) }
: clone(defaults);
if (type === 'levels') {
params.channels = {
...clone(defaults.channels),
...(params.channels || {}),
};
for (const name of Object.keys(defaults.channels)) {
params.channels[name] = { ...clone(defaults.channels[name]), ...(params.channels[name] || {}) };
}
}
if (type === 'curves') {
params.points = {
...clone(defaults.points),
...(params.points || {}),
};
}
if (type === 'selective-color') {
params.ranges = {
...clone(defaults.ranges),
...(params.ranges || {}),
};
for (const name of Object.keys(defaults.ranges)) {
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
}
}
return { type, params };
}
export function adjustmentSpec(layer) {
if (layer?._stagedAdj) return normalizeAdjustmentData(layer._stagedAdj);
return normalizeAdjustmentData(layer?.adjustment);
}
function documentCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
/**
* Replace the already-rendered content under an adjustment layer with its
* adjusted equivalent. Opacity, blend mode, masks, clipping, and group scope
* are applied by drawing back into the current stack target.
*/
export function drawAdjustmentLayer(target, editorState, layer, clippingBase, renderLayer) {
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas) return false;
// During comparison the underlying stack is already on the target. Leaving
// it untouched shows the true before state without creating a history entry.
if (layer._adjCompare) return true;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
// Large live previews use a bounded working surface. The staged layer is
// marked only while its popup is open; committed rendering stays full-size
// so export and reopen remain pixel-accurate.
const maxPreviewEdge = 1400;
const previewScale = layer._adjPreview
? Math.min(1, maxPreviewEdge / Math.max(width, height))
: 1;
const sourceWidth = Math.max(1, Math.round(width * previewScale));
const sourceHeight = Math.max(1, Math.round(height * previewScale));
const source = documentCanvas(sourceWidth, sourceHeight);
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
let adjusted = applyAdjustment(source, adjustmentSpec(layer));
if (previewScale !== 1) {
const expanded = documentCanvas(width, height);
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
adjusted = expanded;
}
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
if (layer.clipped && clippingBase) {
const clipped = documentCanvas(width, height);
const clippedCtx = clipped.getContext('2d');
clippedCtx.drawImage(adjusted, 0, 0);
clippedCtx.globalCompositeOperation = 'destination-in';
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
clippedCtx.drawImage(renderLayer(clippingBase), offset.x, offset.y);
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true;
}
/** Async adjustment rendering for live previews and large documents. */
export async function drawAdjustmentLayerAsync(target, editorState, layer, clippingBase, renderLayer, shouldContinue = () => true) {
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas || !shouldContinue()) return false;
if (layer._adjCompare) return true;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
const maxPreviewEdge = 1400;
const previewScale = layer._adjPreview
? Math.min(1, maxPreviewEdge / Math.max(width, height))
: 1;
const sourceWidth = Math.max(1, Math.round(width * previewScale));
const sourceHeight = Math.max(1, Math.round(height * previewScale));
const source = documentCanvas(sourceWidth, sourceHeight);
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
let adjusted = await renderAdjustmentAsync(source, adjustmentSpec(layer), shouldContinue);
if (!shouldContinue() || !adjusted) return false;
if (previewScale !== 1) {
if (!shouldContinue()) return false;
const expanded = documentCanvas(width, height);
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
adjusted = expanded;
}
if (!shouldContinue()) return false;
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
if (!shouldContinue()) return false;
if (layer.clipped && clippingBase) {
const clipped = documentCanvas(width, height);
const clippedCtx = clipped.getContext('2d');
clippedCtx.drawImage(adjusted, 0, 0);
clippedCtx.globalCompositeOperation = 'destination-in';
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
const baseOutput = await renderLayer(clippingBase);
if (!shouldContinue() || !baseOutput) return false;
clippedCtx.drawImage(baseOutput, offset.x, offset.y);
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true;
}
async function renderAdjustmentAsync(source, adjustment, shouldContinue) {
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
return applyAdjustment(source, adjustment);
}
let sourceBitmap;
try {
sourceBitmap = await createImageBitmap(source);
} catch {
return applyAdjustment(source, adjustment);
}
if (!shouldContinue()) {
sourceBitmap.close?.();
return null;
}
let worker;
try {
worker = new Worker(new URL('./adjustments-worker.js', import.meta.url), { type: 'module' });
} catch {
return shouldContinue() ? applyAdjustment(source, adjustment) : null;
}
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
worker.terminate();
sourceBitmap.close?.();
resolve(result);
};
worker.onmessage = event => {
const { bitmap, error } = event.data || {};
if (error || !bitmap || !shouldContinue()) {
bitmap?.close?.();
finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
return;
}
const output = documentCanvas(source.width, source.height);
output.getContext('2d').drawImage(bitmap, 0, 0);
bitmap.close?.();
finish(output);
};
// A stale worker must not trigger a full-resolution fallback. The caller
// will discard it, and doing the work here makes slider scrubbing worse.
worker.onerror = () => finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
worker.postMessage({ source: sourceBitmap, adjustment }, [sourceBitmap]);
});
}
+26
View File
@@ -0,0 +1,26 @@
/* Worker adapter for the shared pure adjustment pixel pass. */
let applyAdjustment;
async function render(source, adjustment) {
if (!applyAdjustment) {
// pixel-pass only needs document.createElement('canvas'), so provide the
// smallest worker-local adapter instead of maintaining a second renderer.
self.document = { createElement: () => new OffscreenCanvas(source.width, source.height) };
({ applyAdjustment } = await import('./fx/pixel-pass.js'));
}
const input = new OffscreenCanvas(source.width, source.height);
input.getContext('2d').drawImage(source, 0, 0);
return applyAdjustment(input, adjustment);
}
self.onmessage = async event => {
try {
const { source, adjustment } = event.data;
const output = await render(source, adjustment);
const bitmap = output.transferToImageBitmap();
self.postMessage({ bitmap }, [bitmap]);
} catch (error) {
self.postMessage({ error: String(error?.message || error) });
}
};
+52 -34
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, 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,16 +135,7 @@ export function wireInpaintButtons({
} catch (_) { /* overlay is decorative */ }
try {
// Flatten current image.
const flatCanvas = document.createElement('canvas');
flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight;
const flatCtx = flatCanvas.getContext('2d');
for (const layer of state.layers) {
if (!layer.visible) continue;
flatCtx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
flatCtx.drawImage(layer.canvas, off.x, off.y);
}
flatCtx.globalAlpha = 1;
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
@@ -132,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];
@@ -141,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((() => {
@@ -161,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');
@@ -181,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);
@@ -196,7 +214,9 @@ export function wireInpaintButtons({
// on each sub-row's eye icon.
for (const ly of state.layers) {
if (!ly.masks || !ly.masks.length) continue;
for (const mk of ly.masks) mk.visible = false;
for (const mk of ly.masks) {
if (mk.mode !== 'layer') mk.visible = false;
}
}
composite();
renderLayerPanel();
@@ -234,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 (_) {} }
@@ -270,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;
});
}
+11 -6
View File
@@ -36,6 +36,7 @@
* @returns {{ buildSelectionHintMask: () => string | null }}
*/
import { state } from './state.js';
import { selectionMaskToDocument } from './selection-mask.js';
export function wireRembgAndSharpen({
applyImageTool, openCookbookForDependency,
@@ -69,9 +70,9 @@ export function wireRembgAndSharpen({
// which to hide after a successful cutout.
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// applyImageTool finishes after fetch but the new layer is added
// inside img.onload (one tick later). Poll for up to 60 frames
// (~1s) for the new layer to appear before we auto-hide.
// applyImageTool resolves after the returned image is decoded and the
// new layer is inserted. Keep a short poll for defensive compatibility
// with alternate runners that may still commit on the next frame.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
@@ -204,9 +205,13 @@ export function wireRembgAndSharpen({
const w = state.imgWidth, h = state.imgHeight;
if (state.wandMask && state.wandLayerId) {
const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 };
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(state.wandMask, off.x, off.y);
const c = selectionMaskToDocument(
state.wandMask,
state.wandMaskSpace || 'layer',
off,
w,
h,
);
return c.toDataURL('image/png').split(',')[1];
}
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
+17 -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,9 +93,10 @@ 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();
img.onload = () => {
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
{
saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
layer.ctx.drawImage(img, 0, 0);
@@ -102,10 +105,9 @@ export function createApplyImageTool({
composite();
renderLayerPanel();
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
};
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load result', 6000); };
img.src = 'data:image/png;base64,' + data.image;
}
} catch (e) {
if (operation.signal.aborted) return;
// Detect known failure modes and surface an action-toast.
const msg = (e?.message || '').toLowerCase();
const needsImg2Img = (
@@ -137,6 +139,7 @@ export function createApplyImageTool({
}
}
} finally {
operation.finish();
btn.disabled = false;
btn.classList.remove('ge-btn-processing');
try { btnSpinner?.destroy(); } catch {}
+74 -19
View File
@@ -25,16 +25,18 @@
* renderLayerPanel: () => void,
* spinnerModule: object,
* uiModule: object,
* openAdjustmentLayer: (type: string, anchor: HTMLElement) => void,
* }} deps
*
* @returns {{ addEmptyLayer: () => void }}
*/
import { state } from './state.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireAIToolsMisc({
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
spinnerModule, uiModule,
spinnerModule, uiModule, openAdjustmentLayer,
}) {
// ── Harmonize sliders — Color match + Seam fix ──
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
@@ -103,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);
@@ -118,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 }),
@@ -125,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;
@@ -143,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 ──
@@ -165,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'));
@@ -173,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);
@@ -188,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
@@ -210,7 +220,52 @@ export function wireAIToolsMisc({
renderLayerPanel();
composite();
}
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
<button type="button" data-adjustment-type="white-balance"><span class="ge-add-layer-symbol">◑</span><span>White Balance</span></button>
<button type="button" data-adjustment-type="brightness-contrast"><span class="ge-add-layer-symbol">◐</span><span>Brightness / Contrast</span></button>
<button type="button" data-adjustment-type="hue-saturation"><span class="ge-add-layer-symbol">◉</span><span>Hue / Saturation</span></button>
<button type="button" data-adjustment-type="vibrance"><span class="ge-add-layer-symbol">⌁</span><span>Vibrance</span></button>
<button type="button" data-adjustment-type="black-white"><span class="ge-add-layer-symbol">◐</span><span>Black &amp; White</span></button>
<button type="button" data-adjustment-type="shadows-highlights"><span class="ge-add-layer-symbol">◐</span><span>Shadows / Highlights</span></button>
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
`;
document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
const close = () => {
menu.remove();
document.removeEventListener('pointerdown', away, true);
};
const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
};
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => {
const button = clickEvent.target.closest('button');
if (!button) return;
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer();
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
close();
});
});
return { addEmptyLayer };
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Device-independent brush sampling. Rendering stays in stroke-pipeline.js;
* this module only turns irregular pointer input into evenly spaced samples.
*/
export function normalisePressure(value) {
const pressure = Number(value);
if (!Number.isFinite(pressure) || pressure <= 0) return 1;
return Math.max(0.01, Math.min(1, pressure));
}
export function pressureValue(base, pressure, enabled, minimum = 0.08) {
if (!enabled) return base;
return base * Math.max(minimum, normalisePressure(pressure));
}
export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit }) {
let emitted = null;
let filtered = null;
let remainder = 0;
const output = (sample) => {
emitted = { ...sample };
emit(emitted);
};
const begin = (sample) => {
emitted = null;
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
remainder = 0;
output(filtered);
};
const add = (sample) => {
const raw = { ...sample, pressure: normalisePressure(sample.pressure) };
if (!filtered || !emitted) return begin(raw);
const smoothing = Math.max(0, Math.min(0.95, Number(getSmoothing()) || 0));
const response = 1 - smoothing;
filtered = {
x: filtered.x + (raw.x - filtered.x) * response,
y: filtered.y + (raw.y - filtered.y) * response,
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
};
const start = { ...emitted };
const dx = filtered.x - start.x;
const dy = filtered.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
};
const end = (sample) => {
if (sample && emitted) {
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
}
emitted = null;
filtered = null;
remainder = 0;
};
return { begin, add, end };
}
+46
View File
@@ -0,0 +1,46 @@
const STORAGE_KEY = 'odysseus.editor.brush-presets.v1';
const BUILT_INS = [
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
];
export function loadBrushPresets(storage = globalThis.localStorage) {
let custom = [];
try {
const parsed = JSON.parse(storage?.getItem(STORAGE_KEY) || '[]');
if (Array.isArray(parsed)) custom = parsed.filter(item => item && typeof item.name === 'string');
} catch {}
return [...BUILT_INS.map(item => ({ ...item, builtIn: true })), ...custom];
}
export function saveCustomBrushPresets(presets, storage = globalThis.localStorage) {
const custom = (presets || []).filter(item => item && !item.builtIn);
storage?.setItem(STORAGE_KEY, JSON.stringify(custom));
}
export function captureBrushPreset(state, name) {
return {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: String(name || 'Brush').trim() || 'Brush',
size: state.brushSize,
opacity: state.brushOpacity,
flow: state.brushFlow,
softness: state.brushSoftness,
spacing: state.brushSpacing,
smoothing: state.brushSmoothing,
blendMode: state.brushBlendMode,
};
}
export function applyBrushPreset(state, preset) {
if (!preset) return;
state.brushSize = preset.size;
state.brushOpacity = preset.opacity;
state.brushFlow = preset.flow;
state.brushSoftness = preset.softness;
state.brushSpacing = preset.spacing;
state.brushSmoothing = preset.smoothing;
state.brushBlendMode = preset.blendMode || 'source-over';
}
+284 -5
View File
@@ -12,6 +12,22 @@
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">
<label><span>X</span><input id="ge-layer-x" type="number" step="1" inputmode="numeric" /></label>
<label><span>Y</span><input id="ge-layer-y" type="number" step="1" inputmode="numeric" /></label>
<label><span>W</span><input id="ge-layer-width" type="number" readonly title="Edit width with Transform" /></label>
<label><span>H</span><input id="ge-layer-height" type="number" readonly title="Edit height with Transform" /></label>
</div>
</div>
<div id="ge-brush-controls">
<div class="ge-control-row" id="ge-color-row">
<label>Color</label>
@@ -20,9 +36,204 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<div class="ge-control-row">
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
</div>
<div class="ge-section-title">Stroke</div>
<div class="ge-control-row ge-eraser-row">
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
</div>
<div class="ge-control-row ge-eraser-row">
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
</div>
<div class="ge-control-row">
<label for="ge-brush-blend">Blend</label>
<select id="ge-brush-blend">
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
<option value="screen">Screen</option><option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option><option value="color">Color</option>
</select>
</div>
<div class="ge-control-row ge-brush-pressure-row">
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
</div>
<div class="ge-control-row ge-brush-preset-row">
<select id="ge-brush-preset" aria-label="Brush preset"></select>
<button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-brush-preset-delete" title="Delete selected preset" aria-label="Delete selected preset">×</button>
</div>
</div>
<div class="ge-gradient-section" id="ge-gradient-section" style="display:none;">
<div class="ge-section-title">Gradient</div>
<div class="ge-control-row"><label for="ge-gradient-type">Type</label><select id="ge-gradient-type"><option value="linear-gradient">Linear</option><option value="radial-gradient">Radial</option></select></div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-gradient-start" value="${color}" /></label>
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-gradient-end" value="#ffffff" /></label>
</div>
<div class="ge-control-row ge-gradient-mid-row">
<span class="ge-gradient-mid-toggle"><span>Midpoint</span><label class="toggle-switch"><input type="checkbox" id="ge-gradient-mid-enabled" /><span class="toggle-slider"></span></label></span>
<input type="color" class="ge-color-picker" id="ge-gradient-mid" value="#808080" title="Midpoint color" disabled />
<input type="range" id="ge-gradient-mid-position" min="1" max="99" value="50" title="Midpoint position" disabled />
<span id="ge-gradient-mid-position-label">50%</span>
</div>
<div class="ge-gradient-extra-stops" id="ge-gradient-extra-stops"></div>
<button type="button" class="ge-btn ge-btn-sm ge-gradient-add-stop" id="ge-gradient-add-stop">Add stop</button>
<div class="ge-control-row"><label for="ge-gradient-end-alpha">End opacity <span id="ge-gradient-end-alpha-label">100%</span></label><input type="range" id="ge-gradient-end-alpha" min="0" max="100" value="100" /></div>
<div class="ge-control-row"><label for="ge-gradient-opacity">Opacity <span id="ge-gradient-opacity-label">100%</span></label><input type="range" id="ge-gradient-opacity" min="0" max="100" value="100" /></div>
<p class="ge-section-hint">Drag across the active layer. Switch tools or press Esc to cancel.</p>
</div>
<div class="ge-eraser-section" id="ge-eyedropper-section" style="display:none;">
<div class="ge-section-title">Eyedropper</div>
<div class="ge-eyedropper-live" id="ge-eyedropper-live" aria-live="polite">
<canvas class="ge-eyedropper-loupe" id="ge-eyedropper-loupe" width="84" height="84" aria-hidden="true"></canvas>
<span class="ge-eyedropper-live-swatch" id="ge-eyedropper-live-swatch" aria-hidden="true"></span>
<span class="ge-eyedropper-live-values">
<code id="ge-eyedropper-live-value">Move over the canvas</code>
<span id="ge-eyedropper-live-rgb">RGB --</span>
<span id="ge-eyedropper-live-hsl">HSL --</span>
</span>
</div>
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
</div>
<div class="ge-text-section" id="ge-text-section" style="display:none;">
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
<div class="ge-text-grid">
<label class="ge-text-field ge-text-font-field"><span>Font</span>
<select id="ge-text-font">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="system-ui">System</option>
</select>
</label>
<label class="ge-text-field ge-text-size-field"><span>Size</span>
<input id="ge-text-size" type="number" min="1" max="2000" value="48" />
</label>
</div>
<div class="ge-control-row ge-text-format-row">
<input type="color" class="ge-color-picker" id="ge-text-color" value="${color}" title="Text color" aria-label="Text color" />
<button type="button" class="ge-text-toggle" id="ge-text-bold" title="Bold" aria-pressed="false"><strong>B</strong></button>
<button type="button" class="ge-text-toggle" id="ge-text-italic" title="Italic" aria-pressed="false"><em>I</em></button>
<span class="ge-text-format-sep"></span>
<button type="button" class="ge-text-toggle active" data-text-align="left" title="Align left" aria-pressed="true">&#8676;</button>
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">&#8596;</button>
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Line</span>
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
</label>
<label class="ge-text-field"><span>Spacing</span>
<input id="ge-text-letter-spacing" type="number" min="-100" max="500" step="0.5" value="0" />
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Frame</span>
<input id="ge-text-frame-width" type="number" min="1" max="10000" step="1" value="320" />
</label>
<label class="ge-text-field"><span>Height</span>
<input id="ge-text-frame-height" type="number" min="0" max="10000" step="1" value="0" />
</label>
<label class="ge-text-field"><span>Vertical</span>
<select id="ge-text-vertical-align"><option value="top">Top</option><option value="middle">Middle</option><option value="bottom">Bottom</option></select>
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Stroke</span>
<input id="ge-text-stroke-width" type="number" min="0" max="100" step="1" value="0" />
</label>
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
</div>
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
</div>
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
<div class="ge-section-title">Shape</div>
<div class="ge-shape-types" role="group" aria-label="Shape type">
<button type="button" class="ge-text-toggle active" data-shape-type="rectangle" title="Rectangle" aria-pressed="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="1"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="ellipse" title="Ellipse" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="line" title="Line" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="19" x2="20" y2="5"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="polygon" title="Polygon" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 9 7-3.5 11h-11L3 10Z"/></svg></button>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill type</span>
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
</label>
<label class="ge-text-field ge-shape-gradient-angle-field" hidden><span>Angle</span><input id="ge-shape-gradient-angle" type="number" min="-36000" max="36000" step="1" value="0" /></label>
</div>
<div class="ge-text-grid ge-shape-gradient-fields" hidden>
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-start" value="#ffffff" /></label>
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-end" value="#000000" /></label>
<label class="ge-text-field"><span>Midpoint</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-mid" value="#808080" /></label>
<label class="ge-text-field"><span>Position</span><input id="ge-shape-gradient-mid-position" type="number" min="1" max="99" step="1" value="50" /></label>
<label class="ge-text-field ge-shape-gradient-mid-enabled"><span>Use midpoint</span><input id="ge-shape-gradient-mid-enabled" type="checkbox" /></label>
</div>
<div class="ge-shape-gradient-extra-stops" id="ge-shape-gradient-extra-stops"></div>
<button type="button" class="ge-btn ge-btn-sm ge-shape-gradient-add-stop" id="ge-shape-gradient-add-stop">Add stop</button>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Width</span><input id="ge-shape-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
</div>
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
</div>
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14"/></svg>
</button>
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn" data-marquee-shape="ellipse" title="Elliptical marquee" aria-pressed="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg>
</button>
<span style="flex:1"></span>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<div class="ge-marquee-constraint-row">
<label for="ge-marquee-constraint">Style</label>
<select id="ge-marquee-constraint" title="Marquee sizing style">
<option value="free">Free</option>
<option value="ratio">Fixed ratio</option>
<option value="size">Fixed size</option>
</select>
<div class="ge-marquee-dimensions" hidden>
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
<label>H <input type="number" id="ge-marquee-height" min="1" step="1" value="1" inputmode="decimal" /></label>
<button type="button" class="ge-icon-btn" id="ge-marquee-swap" title="Swap width and height" aria-label="Swap width and height">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4"/><path d="M4 7h16"/><path d="m8 21-4-4 4-4"/><path d="M20 17H4"/></svg>
</button>
</div>
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<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-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
</div>
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next lasso combines with the current selection.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
@@ -34,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>
@@ -50,6 +261,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" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
</div>
@@ -58,6 +270,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
@@ -83,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>
@@ -99,6 +312,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" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
</div>
@@ -108,6 +322,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
@@ -128,6 +343,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" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div>
@@ -230,6 +446,17 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<p class="ge-section-hint" style="margin-top:0;">
<strong class="ge-clone-hint-desktop">Alt-click</strong><strong class="ge-clone-hint-mobile">Double-tap</strong> to set source · drag to paint
</p>
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
<span id="ge-clone-source-label">No source selected</span>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
</div>
<div class="ge-control-row">
<label for="ge-clone-sample-mode">Sample</label>
<select id="ge-clone-sample-mode">
<option value="active-layer">Active layer</option>
<option value="composite">All visible layers</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
@@ -247,7 +474,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</div>
</div>
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
<div class="ge-section-title">Brush</div>
<div class="ge-section-title"><span>Brush</span></div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
@@ -387,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>
@@ -396,6 +632,49 @@ export function layerPanelHTML() {
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
<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 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-add-layer" title="Add empty layer">+ Add</button>
</div><div class="ge-layers-list" id="ge-layers-list"></div>`;
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<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-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>
<select id="ge-layer-blend" title="Active layer blend mode">
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option>
<option value="hard-light">Hard Light</option>
<option value="darken">Darken</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
<option value="color-burn">Color Burn</option>
<option value="difference">Difference</option>
<option value="exclusion">Exclusion</option>
<option value="hue">Hue</option>
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
</select>
</div>
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
<span id="ge-layer-selection-count">2 layers</span>
<button class="ge-icon-btn" id="ge-selected-visibility" title="Toggle selected visibility" aria-label="Toggle selected visibility">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-icon-btn" id="ge-selected-lock" title="Toggle selected lock" aria-label="Toggle selected lock">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
</button>
<button class="ge-icon-btn" id="ge-selected-align" title="Align or distribute selected layers" aria-label="Align or distribute selected layers">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16M7 12h10M4 19h16"/><path d="M12 3v18"/></svg>
</button>
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
</div>
<div class="ge-layers-list" id="ge-layers-list"></div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
}
+37 -12
View File
@@ -5,6 +5,8 @@
* el.querySelector after appending.
*/
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
/** Keyboard-shortcuts popover. */
export function shortcutsPopupHTML() {
return `
@@ -22,16 +24,16 @@ export function shortcutsPopupHTML() {
<div class="ge-shortcuts-col">
<h5>Tools</h5>
<div><kbd>V</kbd> Move</div>
<div><kbd>T</kbd> Transform</div>
<div><kbd>H</kbd> Hand <span style="opacity:0.5">(hold Space temporarily)</span></div>
<div><kbd>Arrow</kbd> Move 1 px <span style="opacity:0.5">(Shift = 10 px)</span></div>
<div><kbd>T</kbd> Text</div>
<div><kbd>B</kbd> Brush</div>
<div><kbd>E</kbd> Eraser</div>
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
<div><kbd>${TOOL_SHORTCUTS.clone}</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
<div><kbd>L</kbd> Lasso</div>
<div><kbd>W</kbd> Wand</div>
<div><kbd>M</kbd> Inpaint</div>
<div><kbd>E</kbd> Eraser</div>
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
<div><kbd>C</kbd> Crop</div>
<div><kbd>S</kbd> Sharpen</div>
</div>
<div class="ge-shortcuts-col">
<h5>Edit</h5>
@@ -40,16 +42,18 @@ export function shortcutsPopupHTML() {
<div><kbd>Ctrl</kbd>+<kbd>S</kbd> Save</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
</div>
<div class="ge-shortcuts-col">
<h5>Selection</h5>
<div><kbd>Ctrl</kbd>+<kbd>A</kbd> Select All</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> Deselect</div>
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy to layer</div>
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut lasso</div>
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Delete pixels</div>
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Deselect</div>
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy</div>
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut</div>
<div><kbd>Ctrl</kbd>+<kbd>J</kbd> Copy selection to layer / duplicate layer</div>
<div><kbd>Delete</kbd> Delete pixels</div>
<div><kbd>Esc</kbd> Cancel selection / crop</div>
</div>
<div class="ge-shortcuts-col">
@@ -89,8 +93,19 @@ export function historyPanelHTML(historyIcon) {
export function canvasSizePromptHTML() {
return `
<div class="modal-content ge-canvas-prompt">
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New canvas</h4></div>
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
<div class="modal-body">
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1920x1080"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="1"></rect></svg><span>Landscape</span><strong>1920 × 1080</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1920"><svg class="ge-canvas-preset-icon" width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="1"></rect></svg><span>Story</span><strong>1080 × 1920</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1350"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="1"></rect></svg><span>Portrait</span><strong>1080 × 1350</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="2480x3508"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M6 2h9l3 3v17H6zM15 2v4h4"></path></svg><span>A4</span><strong>2480 × 3508</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="3840x2160"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="1"></rect><path d="M8 22h8M12 20v2"></path></svg><span>4K</span><strong>3840 × 2160</strong></button>
</div>
<div class="ge-canvas-prompt-section-label ge-canvas-prompt-custom-label">Custom size</div>
<label class="ge-canvas-units-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v16H4z"></path><path d="M8 4v16M16 4v16M4 8h16M4 16h16"></path></svg><span>Units</span></span><select id="ge-canvas-prompt-units"><option value="px">Pixels</option><option value="percent">Percent</option></select></label>
<div class="ge-canvas-prompt-row">
<label class="ge-canvas-prompt-field">
<span>Width</span>
@@ -102,9 +117,19 @@ export function canvasSizePromptHTML() {
<input type="text" id="ge-canvas-prompt-h" inputmode="numeric" value="1024">
</label>
</div>
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
<div class="ge-canvas-anchor-options">
<div class="ge-canvas-anchor-label">Anchor</div>
<div class="ge-canvas-anchor-grid" role="group" aria-label="Canvas anchor">
${[['0,0','Top left'],['0.5,0','Top'],['1,0','Top right'],['0,0.5','Left'],['0.5,0.5','Center'],['1,0.5','Right'],['0,1','Bottom left'],['0.5,1','Bottom'],['1,1','Bottom right']].map(([value, label], index) => `<button type="button" class="ge-canvas-anchor${index === 0 ? ' active' : ''}" data-canvas-anchor="${value}" title="${label}" aria-label="${label}"></button>`).join('')}
</div>
</div>
<div class="ge-canvas-resize-options">
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
</div>
</div>
<div class="modal-footer">
<div class="modal-footer ge-canvas-prompt-footer">
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
<button class="confirm-btn confirm-btn-secondary" id="ge-canvas-prompt-cancel">Cancel</button>
<button class="confirm-btn confirm-btn-primary" id="ge-canvas-prompt-ok">Create</button>
</div>
+9
View File
@@ -77,6 +77,10 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
// docked inside the right panel above the layers list.
if (window.innerWidth <= 700 && state.container) {
state.container.appendChild(controls);
// Start canvas-first on phones. The active tool button brings this
// sheet back and minimizes Layers, so the two bottom sheets never
// compete for the same pointer events on initial open.
controls.classList.add('dismissed');
}
// Move every slider-row's value chip out of its <label> and place
@@ -102,6 +106,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
{
const header = layerPanel.querySelector('.ge-layers-header');
if (header) {
const claimLayerSheet = () => {
if (window.innerWidth <= 700) controls.classList.add('dismissed');
};
let sy = 0, sx = 0, dragging = false, didSwipe = false;
header.addEventListener('touchstart', (e) => {
if (window.innerWidth > 700) return;
@@ -121,6 +128,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
// expanded → peek → minimized (swipe down)
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
didSwipe = true;
claimLayerSheet();
const isExpanded = rightPanel.classList.contains('expanded');
const isMinimized = rightPanel.classList.contains('minimized');
if (dy < 0) {
@@ -143,6 +151,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
if (window.innerWidth > 700) return;
if (e.target.closest('button')) return;
if (didSwipe) { didSwipe = false; return; }
claimLayerSheet();
// Click cycles between peek and expanded; minimized comes
// back to peek (so a tap on the handle always reveals at
// least the active layer row).
+27 -12
View File
@@ -9,32 +9,46 @@
* @param {{
* currentTool: string,
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
* onClearSelection: (which: 'lasso'|'wand') => void,
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
* }} ctx
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
*/
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const toolbar = document.createElement('div');
toolbar.className = 'ge-toolbar';
const tools = [
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
{ id: 'transform', label: 'Transform', icon: '⤢', key: 'T' },
{ id: 'move', label: 'Move', icon: '✥' },
{ id: 'hand', label: 'Hand', 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="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
{ id: 'crop', label: 'Crop', icon: '✂' },
{ id: 'transform', label: 'Transform', icon: '⤢' },
{ id: 'text', label: 'Text', 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 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
{ id: 'shape', label: 'Shape', 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="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
{ sep: true },
{ id: 'brush', label: 'Brush', 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="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'B' },
{ id: 'eraser', label: 'Eraser', 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="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
{ id: 'brush', label: 'Brush', 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="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
{ id: 'gradient', label: 'Gradient', 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 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
{ id: 'eraser', label: 'Eraser', 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="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
{ id: 'eyedropper', label: 'Eyedropper', 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="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
{ id: 'smudge', label: 'Smudge', 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="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
{ sep: true },
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
{ 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>', key: 'W' },
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
{ 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 },
{ id: 'inpaint', label: 'Inpaint', 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="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
{ id: 'inpaint', label: 'Inpaint', 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="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈', key: 'S' },
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
];
const toolKeyMap = {};
for (const t of tools) {
t.key = TOOL_SHORTCUTS[t.id];
if (t.sep) {
const sep = document.createElement('div');
sep.className = 'ge-tool-sep';
@@ -47,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.
@@ -56,7 +71,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
// selection (lasso, wand). Inpaint masks are first-class sub-layers
// now so they get their own delete-X in the layer panel.
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
const clearBadge = (t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
'</span>'
+114 -29
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
@@ -26,34 +26,41 @@ 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="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
<span class="ge-stacked-label">HISTORY</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 class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" 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"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
<span class="ge-stacked-label">BEFORE</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>
<span class="ge-stacked-label">VIEW</span>
</button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
</div>
</div>
<div class="ge-image-wrap">
<button class="ge-btn ge-btn-sm" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">Image ▾</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" 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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
<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="resize">
<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…</span>
<span>Canvas Size...</span>
</button>
<button class="dropdown-item-compact" data-image-action="image-size">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
<span>Image Size...</span>
</button>
<div class="ge-filter-submenu-label">Transform</div>
<button class="dropdown-item-compact" data-image-action="rotate-90">
@@ -74,9 +81,61 @@ export function buildTopbar() {
</button>
</div>
</div>
<div class="ge-selection-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" 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"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">SELECT</span>
</button>
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
<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+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>
<div class="ge-selection-save-row">
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
<button type="button" class="ge-icon-btn" data-selection-action="save" title="Save current selection" aria-label="Save current selection">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</button>
</div>
<div class="ge-filter-submenu-label">Saved selections</div>
<div id="ge-saved-selection-list" class="ge-saved-selection-list"></div>
</div>
</div>
<div class="ge-filter-wrap">
<button class="ge-btn ge-btn-sm" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">Filter ▾</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" 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="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">FILTER</span>
</button>
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
<div class="ge-filter-submenu-label">Retained effects</div>
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
<span class="dropdown-icon" aria-hidden="true">◈</span>
<span>Sharpen (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
<span class="dropdown-icon" aria-hidden="true">◐</span>
<span>Color Overlay (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
<span class="dropdown-icon" aria-hidden="true">◒</span>
<span>Drop Shadow (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
<span class="dropdown-icon" aria-hidden="true">□</span>
<span>Stroke (retained)…</span>
</button>
<div class="ge-filter-submenu-label">Presets</div>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
<div class="ge-filter-submenu-label">Blur</div>
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
@@ -92,10 +151,17 @@ export function buildTopbar() {
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-import-topbar" title="Import image as layer">+ Import</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
<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 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
<span class="ge-stacked-label">IMPORT</span>
</button>
<div class="ge-save-wrap">
<button class="ge-btn ge-btn-primary" id="ge-save-menu-btn" title="Save options" style="display:inline-flex;align-items:center;gap:4px;">Save
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><polyline points="6 9 12 15 18 9"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
<span class="ge-stacked-glyph">
<svg hidden class="ge-save-state-icon ge-save-state-dirty" 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="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
</button>
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
<div class="dropdown-section-label">Image</div>
@@ -109,9 +175,9 @@ export function buildTopbar() {
<span>Save as copy</span>
<span class="dropdown-shortcut">Ctrl+Shift+S</span>
</button>
<button class="dropdown-item-compact" id="ge-download" title="Download PNG to your computer">
<button class="dropdown-item-compact" id="ge-download" title="Export an image to your computer">
<span class="dropdown-icon"><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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span>Download PNG</span>
<span>Export image...</span>
</button>
<div class="dropdown-section-divider"></div>
<div class="dropdown-section-label">Project</div>
@@ -129,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;
}
+32
View File
@@ -23,6 +23,23 @@ export function transformPopupHTML() {
</span>
</div>
<div class="ge-transform-popup-body">
<div class="ge-transform-field ge-transform-position-field">
<label for="ge-transform-x" title="Transform center X">X</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-x" step="1" aria-label="Transform center X" />
<span class="ge-transform-spin" data-spin-for="ge-transform-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Move left">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Move right">+</button>
</span>
</div>
<div class="ge-transform-field ge-transform-position-field">
<label for="ge-transform-y" title="Transform center Y">Y</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-y" step="1" aria-label="Transform center Y" />
<span class="ge-transform-spin" data-spin-for="ge-transform-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Move up">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Move down">+</button>
</span>
</div>
<div class="ge-row-break ge-transform-position-break"></div>
<div class="ge-transform-field">
<label>W</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
@@ -48,9 +65,24 @@ export function transformPopupHTML() {
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
</span>
</div>
<span class="ge-transform-quick" aria-label="Transform actions">
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
<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 d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
</button>
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-v" title="Flip vertically" aria-label="Flip vertically">
<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 12h18"/><path d="m7 8 5-4 5 4"/><path d="m7 16 5 4 5-4"/></svg>
</button>
<button type="button" class="ge-transform-quick-btn" id="ge-transform-rot-90" title="Rotate 90 degrees (Shift: counter-clockwise)" aria-label="Rotate 90 degrees">
<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="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg>
</button>
</span>
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
</div>
<label class="ge-transform-preserve-field">
<input type="checkbox" id="ge-transform-preserve-source" />
<span>Preserve source pixels</span>
</label>
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
`;
}
+153 -47
View File
@@ -14,13 +14,13 @@
* Touch:
* touchstart 1 finger → beginDraw
* touchmove 1 finger → continueDraw
* touchend / touchcancel → endDraw
* touchend → endDraw; touchcancel → cancelDraw
* touchstart 2 fingers → pinch-zoom + 2-finger pan
*
* Pan (any free space around the canvas):
* pointerdown / pointermove / pointerup on canvas-area, skipping
* the canvas + transform overlay + UI elements above them. Sets
* canvasArea.dataset.panX/Y + CSS transform on both canvases.
* Pan:
* Hand tool / held Space / middle mouse can drag directly over the
* image. Empty workspace remains draggable with any tool. The same
* path drives one-finger Hand-tool panning on touch screens.
*
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
* the pan offset.
@@ -30,19 +30,70 @@
* beginDraw: (e: Event) => void,
* continueDraw: (e: Event) => void,
* endDraw: (e?: Event) => void,
* cancelDraw?: () => void,
* updateBrushCursor: (e: Event) => void,
* syncZoomControls?: () => void,
* }} ctx
*/
import { state } from './state.js';
import {
applyCanvasPan,
isDirectPanIntent,
nextPanOffset,
syncPanCursor,
} from './canvas-navigation.js';
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
let canvasWindowBindings;
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings;
let suppressMouseUntil = 0;
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
// the WINDOW so a drag can continue (and end) past the canvas edge.
// Critical for the Resize tool where users overshoot.
state.mainCanvas.addEventListener('mousedown', beginDraw);
window.addEventListener('mousemove', continueDraw);
window.addEventListener('mouseup', endDraw);
state.mainCanvas.addEventListener('mousedown', (e) => {
if (Date.now() < suppressMouseUntil) return;
if (isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
beginDraw(e);
});
window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return;
continueDraw(e);
}, { signal });
window.addEventListener('mouseup', (e) => {
if (Date.now() < suppressMouseUntil) return;
endDraw(e);
}, { signal });
// Preserve pressure and browser-coalesced samples for pen input.
// Compatibility mouse events are briefly suppressed to avoid a
// duplicate stroke after pointerup.
let activePenId = null;
state.mainCanvas.addEventListener('pointerdown', (e) => {
if (e.pointerType !== 'pen') return;
suppressMouseUntil = Date.now() + 500;
activePenId = e.pointerId;
try { state.mainCanvas.setPointerCapture(activePenId); } catch {}
beginDraw(e);
e.preventDefault();
});
state.mainCanvas.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
continueDraw(e);
e.preventDefault();
});
const endPen = (e) => {
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
if (e.type === 'pointercancel') cancelDraw?.();
else endDraw(e);
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
activePenId = null;
suppressMouseUntil = Date.now() + 500;
e.preventDefault();
};
state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a lasso path in
// the empty space around the image. Other tools stay canvas-only.
@@ -52,12 +103,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
beginDraw(e);
});
state.mainCanvas.addEventListener('mouseenter', (e) => {
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].includes(state.tool)) updateBrushCursor(e);
if (state.tool === 'eyedropper') updateEyedropperPreview?.(e);
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(state.tool)) updateBrushCursor(e);
});
state.mainCanvas.addEventListener('mouseleave', () => {
// Only hide the brush-cursor overlay on leave — DO NOT end the
// drag, so the user can drag a resize handle past the canvas edge.
if (state.cursorEl) state.cursorEl.style.display = 'none';
if (state.tool === 'eyedropper') updateEyedropperPreview?.(null, true);
if (state.tool === 'transform' && state.hoveredHandle) {
state.hoveredHandle = null;
state.mainCanvas.style.cursor = 'default';
// The frame is drawn on a separate overlay, so clear its hover state
// explicitly when the pointer leaves the source canvas.
continueDraw?.({ clientX: -1, clientY: -1, target: null });
}
});
// Touch — single finger draws; two fingers pan + pinch-zoom.
@@ -74,13 +134,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const dy = t2.clientY - t1.clientY;
return { cx, cy, dist: Math.hypot(dx, dy) };
};
const applyCanvasOffset = (x, y) => {
canvasArea.dataset.panX = String(x);
canvasArea.dataset.panY = String(y);
const t = `translate3d(${x}px, ${y}px, 0)`;
state.mainCanvas.style.transform = t;
if (state.transformOverlay) state.transformOverlay.style.transform = t;
};
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
state.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length >= 2) {
@@ -91,13 +145,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
multiStartDist = info.dist;
multiStartZoom = state.zoom;
multiStartCenter = { x: info.cx, y: info.cy };
multiStartPan = {
x: parseFloat(canvasArea.dataset.panX || '0') || 0,
y: parseFloat(canvasArea.dataset.panY || '0') || 0,
};
multiStartPan = { x: state.panX || 0, y: state.panY || 0 };
return;
}
if (multiActive) return;
if (state.tool === 'hand') return;
beginDraw(e);
}, { passive: false });
state.mainCanvas.addEventListener('touchmove', (e) => {
@@ -113,10 +165,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.();
onViewportChange?.();
}
const dx = info.cx - multiStartCenter.x;
const dy = info.cy - multiStartCenter.y;
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
onViewportChange?.();
return;
}
if (multiActive) return;
@@ -131,67 +185,119 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
});
state.mainCanvas.addEventListener('touchcancel', () => {
multiActive = false;
endDraw();
cancelDraw?.();
});
// Press-and-drag in the empty space AROUND the canvas pans the
// canvas + overlay via CSS transform. Works even when the image
// fits the viewport (no scroll needed). Skips presses on the canvas
// itself (the canvas owns its own drawing input) or on UI elements
// above it.
// Direct pan gestures own the image as well as the surrounding area.
// With other tools only empty workspace pans, preserving normal edit input.
let panning = false;
let pid = null;
let transformPointerId = null;
let startX = 0, startY = 0;
const getOffset = () => {
const v = canvasArea.dataset.panX || '0';
const u = canvasArea.dataset.panY || '0';
return { x: parseFloat(v) || 0, y: parseFloat(u) || 0 };
};
let startPanX = 0, startPanY = 0;
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
const applyOffset = (x, y) => {
canvasArea.dataset.panX = String(x);
canvasArea.dataset.panY = String(y);
const t = `translate3d(${x}px, ${y}px, 0)`;
state.mainCanvas.style.transform = t;
if (state.transformOverlay) state.transformOverlay.style.transform = t;
const result = applyCanvasPan(state, canvasArea, x, y);
onViewportChange?.();
return result;
};
canvasArea.addEventListener('pointerdown', (e) => {
if (state.tool === 'lasso') return;
if (e.target === state.mainCanvas || e.target === state.transformOverlay) return;
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
if (state.tool === 'lasso' && !directPan) return;
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return;
// During an active transform the corner/rotation handles render
// OUTSIDE the canvas (over the surrounding area), and the overlay is
// pointer-events:none — so a grab on an outside handle lands here.
// Route it to the transform tool (getHandleAt works in image space,
// even for points beyond the canvas) instead of panning the canvas.
if (state.transformActive) {
if (state.transformActive && !directPan) {
beginDraw(e);
// Only swallow the event (skip pan) if a handle was grabbed OR the
// layer-move fallback engaged; otherwise let the pan logic below
// run so empty space still pans while the transform tool is open.
if (state.transformHandle || state.moving) return;
if (state.transformHandle || state.moving) {
// Mouse drags already continue on window mousemove. Pen/touch drags
// that start on an outside-canvas handle need pointer capture so the
// session survives leaving the editor surface.
if (e.pointerType && e.pointerType !== 'mouse') {
transformPointerId = e.pointerId;
try { canvasArea.setPointerCapture(transformPointerId); } catch {}
e.preventDefault();
}
return;
}
}
const off = getOffset();
panning = true;
pid = e.pointerId;
startX = e.clientX - off.x;
startY = e.clientY - off.y;
startX = e.clientX;
startY = e.clientY;
startPanX = off.x;
startPanY = off.y;
try { canvasArea.setPointerCapture(pid); } catch {}
canvasArea.style.cursor = 'grabbing';
state.navigationPanning = true;
syncPanCursor(state, canvasArea, true);
e.preventDefault();
});
canvasArea.addEventListener('pointermove', (e) => {
if (transformPointerId !== null && e.pointerId === transformPointerId) {
continueDraw(e);
return;
}
if (!panning || e.pointerId !== pid) return;
applyOffset(e.clientX - startX, e.clientY - startY);
const next = nextPanOffset(
{ x: startX, y: startY },
{ x: startPanX, y: startPanY },
{ x: e.clientX, y: e.clientY },
);
applyOffset(next.x, next.y);
});
const endPan = () => {
const endPan = (e) => {
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
const capturedId = transformPointerId;
transformPointerId = null;
if (e?.type === 'pointercancel') cancelDraw?.();
else endDraw(e);
try { canvasArea.releasePointerCapture(capturedId); } catch {}
}
if (!panning) return;
panning = false;
try { canvasArea.releasePointerCapture(pid); } catch {}
pid = null;
canvasArea.style.cursor = '';
state.navigationPanning = false;
syncPanCursor(state, canvasArea, false);
};
canvasArea.addEventListener('pointerup', endPan);
canvasArea.addEventListener('pointercancel', endPan);
// Treat focus loss as release: preserve the work already drawn, but never
// resume the gesture when the user returns without a fresh pointer press.
window.addEventListener('blur', () => {
if (!state.editorOpen || !canvasArea.contains(state.mainCanvas)) return;
endDraw();
if (activePenId !== null) {
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
activePenId = null;
}
multiActive = false;
endPan();
state.spacePanActive = false;
syncPanCursor(state, canvasArea, false);
if (state.cursorEl) state.cursorEl.style.display = 'none';
}, { signal });
// Reset offset whenever zoom/fit changes the canvas size.
canvasArea._resetPan = () => applyOffset(0, 0);
const navigation = {
resetPan: canvasArea._resetPan,
setTemporaryPan(active) {
state.spacePanActive = !!active;
syncPanCursor(state, canvasArea, panning);
},
updateCursor() {
syncPanCursor(state, canvasArea, panning);
},
};
navigation.updateCursor();
return navigation;
}
+31
View File
@@ -0,0 +1,31 @@
/** Canvas viewport navigation shared by mouse, pen, and touch input. */
export function isDirectPanIntent(tool, temporaryPan, button = 0) {
return tool === 'hand' || temporaryPan === true || button === 1;
}
export function nextPanOffset(startPointer, startPan, currentPointer) {
return {
x: startPan.x + currentPointer.x - startPointer.x,
y: startPan.y + currentPointer.y - startPointer.y,
};
}
export function applyCanvasPan(state, canvasArea, x, y) {
const panX = Number.isFinite(Number(x)) ? Number(x) : 0;
const panY = Number.isFinite(Number(y)) ? Number(y) : 0;
state.panX = panX;
state.panY = panY;
canvasArea.dataset.panX = String(panX);
canvasArea.dataset.panY = String(panY);
const transform = `translate3d(${panX}px, ${panY}px, 0)`;
if (state.mainCanvas) state.mainCanvas.style.transform = transform;
if (state.selectionOverlay) state.selectionOverlay.style.transform = transform;
if (state.transformOverlay) state.transformOverlay.style.transform = transform;
return { x: panX, y: panY };
}
export function syncPanCursor(state, canvasArea, panning = false) {
const ready = state.tool === 'hand' || state.spacePanActive === true;
canvasArea.classList.toggle('ge-pan-ready', ready && !panning);
canvasArea.classList.toggle('ge-panning', panning);
}
+10 -74
View File
@@ -14,11 +14,15 @@
* fitZoom: () => void,
* showCanvasLoading: (label: string) => void,
* hideCanvasLoading: () => void,
* renderLayerPanel: () => void,
* }} deps
*/
import { state } from './state.js';
import { flipDocument, rotateDocument } from './document-geometry.js';
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
export function createCanvasTransforms({
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
}) {
return {
/**
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
@@ -34,61 +38,14 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
if (!state.layers.length) return;
saveState(`Rotate ${deg}°`);
showCanvasLoading('Rotating…');
const oldW = state.imgWidth, oldH = state.imgHeight;
const swap = (deg === 90 || deg === 270);
const newW = swap ? oldH : oldW;
const newH = swap ? oldW : oldH;
const rad = (deg * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
requestAnimationFrame(() => {
try {
for (const layer of state.layers) {
const lw = layer.canvas.width, lh = layer.canvas.height;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
// Layer centre in old image coords.
const cx = off.x + lw / 2;
const cy = off.y + lh / 2;
// Rotate the centre around the old image centre and
// translate so the new image centre lands at (newW/2, newH/2).
const dx = cx - oldW / 2;
const dy = cy - oldH / 2;
const nx = dx * cos - dy * sin + newW / 2;
const ny = dx * sin + dy * cos + newH / 2;
// New per-layer dims: swap when 90/270.
const newLw = swap ? lh : lw;
const newLh = swap ? lw : lh;
const tmp = document.createElement('canvas');
tmp.width = newLw; tmp.height = newLh;
const tctx = tmp.getContext('2d');
tctx.translate(newLw / 2, newLh / 2);
tctx.rotate(rad);
tctx.drawImage(layer.canvas, -lw / 2, -lh / 2);
layer.canvas.width = newLw;
layer.canvas.height = newLh;
layer.ctx.drawImage(tmp, 0, 0);
// The adjustment-render caches are keyed only by the adjustment
// signature, which rotation doesn't change — so composite would draw
// the STALE pre-rotation cache (the "had to click twice" bug). Drop
// them so the next composite re-renders from the rotated canvas.
layer._adjCacheKey = null;
layer._adjFinalKey = null;
state.layerOffsets.set(layer.id, {
x: Math.round(nx - newLw / 2),
y: Math.round(ny - newLh / 2),
});
}
state.imgWidth = newW;
state.imgHeight = newH;
state.mainCanvas.width = newW;
state.mainCanvas.height = newH;
if (state.maskCanvas) {
state.maskCanvas.width = newW;
state.maskCanvas.height = newH;
}
const result = rotateDocument(state, deg);
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
fitZoom();
composite();
renderLayerPanel?.();
} finally {
hideCanvasLoading();
}
@@ -103,30 +60,9 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
flipAll(axis) {
if (!state.layers.length) return;
saveState(axis === 'h' ? 'Flip horizontal' : 'Flip vertical');
for (const layer of state.layers) {
const lw = layer.canvas.width, lh = layer.canvas.height;
const tmp = document.createElement('canvas');
tmp.width = lw; tmp.height = lh;
const tctx = tmp.getContext('2d');
tctx.save();
if (axis === 'h') { tctx.translate(lw, 0); tctx.scale(-1, 1); }
else { tctx.translate(0, lh); tctx.scale(1, -1); }
tctx.drawImage(layer.canvas, 0, 0);
tctx.restore();
layer.ctx.clearRect(0, 0, lw, lh);
layer.ctx.drawImage(tmp, 0, 0);
// Invalidate the adjustment-render caches (keyed by adjustment sig only)
// so composite redraws from the flipped canvas, not a stale cache.
layer._adjCacheKey = null;
layer._adjFinalKey = null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
if (axis === 'h') {
state.layerOffsets.set(layer.id, { x: state.imgWidth - off.x - lw, y: off.y });
} else {
state.layerOffsets.set(layer.id, { x: off.x, y: state.imgHeight - off.y - lh });
}
}
flipDocument(state, axis);
composite();
renderLayerPanel?.();
},
};
}
+39 -23
View File
@@ -22,58 +22,74 @@
* createLayer: (name: string, w: number, h: number) => object,
* renderLayerPanel: () => void,
* composite: () => void,
* handleImportedImage: (img: HTMLImageElement) => void,
* handleImportedImage: (img: HTMLImageElement, sourceName?: string) => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
let clipboardBindings;
export function wireClipboardAndDrop({
container, saveState, createLayer, renderLayerPanel, composite,
handleImportedImage, uiModule,
}) {
clipboardBindings?.abort();
clipboardBindings = new AbortController();
const { signal } = clipboardBindings;
// ── Paste ──
window.addEventListener('paste', (e) => {
if (!state.editorOpen) 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) {
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
if (!state.editorOpen) return; // user closed mid-paste
saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
layer.ctx.drawImage(imgSource, 0, 0);
// Selection clipboard data is already a complete layer-sized surface.
// Keep it source-backed with an identity matrix so future transforms do
// not repeatedly resample the pasted pixels.
layer.kind = 'placed';
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
const rendered = renderPlacedLayer(layer);
state.layerOffsets.set(layer.id, rendered.offset);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.tool = 'move';
state.selectedLayerIds = [layer.id];
state.activeGroupId = null;
const tb = state.container?.querySelector('.ge-toolbar');
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
tb?.querySelector('[data-tool="move"]')?.click();
renderLayerPanel();
composite();
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');
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();
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
// 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;
}
}, true); // capture phase so we beat chat input
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 });
// ── Drag-and-drop ──
// Visual drop-zone overlay appears mid-drag; routes via
+33
View File
@@ -72,6 +72,7 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
for (const ly of layers) {
if (!ly.masks || !ly.masks.length) continue;
for (const mk of ly.masks) {
if (mk.mode === 'layer') continue;
if (!mk.visible) continue;
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
ctx.drawImage(mk.canvas, 0, 0);
@@ -81,3 +82,35 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
ctx.globalCompositeOperation = 'source-over';
return anyMask ? out : null;
}
/** Apply visible true layer masks to an already-adjusted layer canvas. */
export function renderWithLayerMasks(source, layer, layerOffset = { x: 0, y: 0 }) {
const masks = (layer?.masks || []).filter(mask => mask.mode === 'layer' && mask.visible !== false);
if (!source || masks.length === 0) return source;
const out = document.createElement('canvas');
out.width = source.width;
out.height = source.height;
const ctx = out.getContext('2d');
ctx.drawImage(source, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
ctx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
ctx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
if (mask.space === 'document') {
ctx.drawImage(mask.canvas, -(layerOffset.x || 0), -(layerOffset.y || 0));
} else {
const offset = mask.offset && typeof mask.offset === 'object' ? mask.offset : { x: 0, y: 0 };
ctx.drawImage(mask.canvas, Number(offset.x) || 0, Number(offset.y) || 0);
}
}
ctx.globalAlpha = 1;
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
return out;
}
@@ -0,0 +1,81 @@
/** Shared lifecycle guard for frame-style pointer interactions. */
export function createDirectManipulationSession({
name = 'interaction',
getContext = () => null,
isContextCurrent = () => true,
onCancel = () => {},
} = {}) {
let active = null;
let nextId = 1;
const pointerMatches = event => {
if (!active || active.pointerId == null || event?.pointerId == null) return true;
return active.pointerId === event.pointerId;
};
const releaseCapture = session => {
if (!session?.captureTarget || session.pointerId == null) return;
try { session.captureTarget.releasePointerCapture(session.pointerId); } catch {}
};
const contextIsCurrent = () => active && isContextCurrent(active.context, active.data);
function begin(event, data = {}, options = {}) {
if (active) cancel('restarted');
const pointerId = Number.isFinite(event?.pointerId) ? event.pointerId : null;
const captureTarget = options.captureTarget || null;
active = {
id: nextId++,
name,
pointerId,
captureTarget,
context: getContext(),
data,
};
if (captureTarget && pointerId != null) {
try { captureTarget.setPointerCapture(pointerId); } catch {}
}
return active;
}
function update(event, callback) {
if (!active || !pointerMatches(event)) return false;
if (!contextIsCurrent()) {
cancel('stale-context');
return false;
}
callback?.(active.data, active);
return true;
}
function commit(event, callback) {
if (!active || !pointerMatches(event)) return false;
if (!contextIsCurrent()) {
cancel('stale-context');
return false;
}
const completed = active;
active = null;
releaseCapture(completed);
callback?.(completed.data, completed);
return true;
}
function cancel(reason = 'cancelled') {
if (!active) return false;
const cancelled = active;
active = null;
releaseCapture(cancelled);
onCancel(cancelled.data, reason, cancelled);
return true;
}
return {
begin,
update,
commit,
cancel,
get active() { return active; },
};
}
+751
View File
@@ -0,0 +1,751 @@
/**
* Lossless, versioned editor document serialization.
*
* Draft autosave and downloaded project files must use this same boundary.
* Keeping a second hand-written layer shape is how masks and adjustments were
* previously dropped when a document was reopened.
*/
import { normalizeAdjustmentData } from './adjustment-layer.js';
import { normalizeEffect } from './effects.js';
import { normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js';
export const EDITOR_DOCUMENT_VERSION = 15;
export const EDITOR_PROJECT_MAX_BYTES = 256 * 1024 * 1024;
export const EDITOR_MAX_DIMENSION = 32768;
export const EDITOR_MAX_DOCUMENT_PIXELS = 100_000_000;
export const EDITOR_MAX_SURFACE_PIXELS = 300_000_000;
export const EDITOR_MAX_LAYERS = 256;
export const EDITOR_MAX_MASKS_PER_LAYER = 64;
export const EDITOR_MAX_GROUPS = 128;
export const EDITOR_MAX_SAVED_SELECTIONS = 64;
const MAX_SERIALIZED_GUIDES = 1000;
const SUPPORTED_BLEND_MODES = new Set([
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity',
]);
function normalizeGuideValues(values, fallback = []) {
const source = Array.isArray(values) ? values : fallback;
const normalized = [];
for (const raw of Array.isArray(source) ? source : []) {
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
if (!normalized.some(existing => Math.abs(existing - value) < 0.0001)) normalized.push(value);
if (normalized.length >= MAX_SERIALIZED_GUIDES) break;
}
return normalized.sort((a, b) => a - b);
}
export function normalizeEditorView(view, fallback = {}) {
const source = view && typeof view === 'object' ? view : {};
const fallbackGuides = fallback.guides && typeof fallback.guides === 'object'
? fallback.guides
: { vertical: [], horizontal: [] };
const guides = source.guides && typeof source.guides === 'object' ? source.guides : {};
const rawGridSize = Number(source.gridSize ?? fallback.gridSize ?? 16);
return {
rulersVisible: Boolean(source.rulersVisible ?? fallback.rulersVisible ?? true),
gridVisible: Boolean(source.gridVisible ?? fallback.gridVisible ?? false),
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
guides: {
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
},
};
}
export function cloneDocumentValue(value, fallback) {
try {
return value == null ? fallback : JSON.parse(JSON.stringify(value));
} catch {
return fallback;
}
}
export class EditorDocumentError extends Error {
constructor(message, code = 'invalid-document') {
super(message);
this.name = 'EditorDocumentError';
this.code = code;
}
}
const MIGRATIONS = new Map([
[1, document => ({
...document,
v: 2,
layers: (document.layers || []).map(layer => ({
visible: true,
opacity: 1,
locked: false,
offset: { x: 0, y: 0 },
...layer,
dataUrl: layer?.dataUrl || layer?.dataURL || null,
})),
})],
[2, document => ({
...document,
v: 3,
layers: (document.layers || []).map(layer => ({
adjustments: {},
adjLayers: [],
activeMaskId: null,
masks: [],
...layer,
})),
})],
[3, document => ({
...document,
v: 4,
layers: (document.layers || []).map(layer => ({
kind: 'raster',
text: null,
...layer,
masks: (layer?.masks || []).map(mask => ({
mode: 'selection',
...mask,
space: mask?.space || (mask?.mode === 'layer' ? 'layer' : 'document'),
})),
})),
})],
[4, document => ({
...document,
v: 5,
view: normalizeEditorView(document.view),
})],
[5, document => ({
...document,
v: 6,
groups: [],
})],
[6, document => ({
...document,
v: 7,
layers: (document.layers || []).map(layer => ({ clipped: false, ...layer })),
})],
[7, document => ({
...document,
v: 8,
groups: (document.groups || []).map(group => ({ parentId: null, ...group })),
})],
[8, document => ({
...document,
v: 9,
groups: (document.groups || []).map(group => ({ masks: [], activeMaskId: null, ...group })),
})],
[9, document => ({
...document,
v: 10,
layers: (document.layers || []).map(layer => ({
locks: { pixels: false, transparency: false, position: false },
...layer,
})),
})],
[10, document => ({
...document,
v: 11,
savedSelections: [],
})],
[11, document => ({
...document,
v: 12,
layers: (document.layers || []).map(layer => ({
...layer,
masks: (layer.masks || []).map(mask => mask?.mode === 'layer' ? {
linked: true,
offset: { x: 0, y: 0 },
...mask,
} : mask),
})),
})],
[12, document => ({
...document,
v: 13,
layers: (document.layers || []).map(layer => ({ placed: null, ...layer })),
})],
[13, document => ({
...document,
v: 14,
layers: (document.layers || []).map(layer => ({ adjustment: null, ...layer })),
})],
[14, document => ({
...document,
v: 15,
layers: (document.layers || []).map(layer => ({ effects: [], ...layer })),
groups: (document.groups || []).map(group => ({ effects: [], ...group })),
})],
]);
function migrateEditorDocument(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new EditorDocumentError('Project root must be a JSON object.');
}
const storedVersion = raw.v == null ? 1 : Number(raw.v);
if (!Number.isInteger(storedVersion) || storedVersion < 1) {
throw new EditorDocumentError('Project has an invalid document version.', 'invalid-version');
}
if (storedVersion > EDITOR_DOCUMENT_VERSION) {
throw new EditorDocumentError(
`This project uses version ${storedVersion}; this editor supports up to version ${EDITOR_DOCUMENT_VERSION}.`,
'future-version',
);
}
let document = { ...raw };
while (document.v == null || document.v < EDITOR_DOCUMENT_VERSION) {
const version = document.v == null ? 1 : document.v;
const migrate = MIGRATIONS.get(version);
if (!migrate) throw new EditorDocumentError(`No migration is available for project version ${version}.`, 'missing-migration');
document = migrate(document);
}
return { document, storedVersion };
}
function positiveDimension(value, fallback = 0) {
const numeric = Math.round(Number(value ?? fallback));
return Number.isFinite(numeric) && numeric > 0 && numeric <= EDITOR_MAX_DIMENSION ? numeric : null;
}
function isEmbeddedPng(value) {
return typeof value === 'string' && value.startsWith('data:image/png;base64,');
}
function finiteCoordinate(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) && Math.abs(numeric) <= 1_000_000 ? numeric : fallback;
}
function normalizePlacedRecord(source) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
const sourceWidth = positiveDimension(source.sourceWidth);
const sourceHeight = positiveDimension(source.sourceHeight);
const matrix = Array.isArray(source.matrix) && source.matrix.length === 6
? source.matrix.map(Number)
: null;
if (!sourceWidth || !sourceHeight || !matrix?.every(Number.isFinite) || !isEmbeddedPng(source.sourceDataUrl)) {
return null;
}
if (matrix.some(value => Math.abs(value) > 1_000_000)) return null;
return {
sourceWidth,
sourceHeight,
sourceName: typeof source.sourceName === 'string'
? source.sourceName.slice(0, 200)
: 'Placed image',
sourceDataUrl: source.sourceDataUrl,
matrix,
};
}
/** Migrate and validate untrusted draft/project JSON before allocating canvases. */
export function prepareEditorDocument(raw) {
const { document: migrated, storedVersion } = migrateEditorDocument(raw);
const warnings = [];
const warn = message => {
if (warnings.length < 50) warnings.push(message);
};
const width = positiveDimension(migrated.imgWidth);
const height = positiveDimension(migrated.imgHeight);
if (!width || !height) {
throw new EditorDocumentError(`Project dimensions must be between 1 and ${EDITOR_MAX_DIMENSION} pixels.`, 'invalid-dimensions');
}
if (width * height > EDITOR_MAX_DOCUMENT_PIXELS) {
throw new EditorDocumentError('Project canvas exceeds the 100 megapixel safety limit.', 'pixel-budget');
}
if (!Array.isArray(migrated.layers)) {
throw new EditorDocumentError('Project is missing its layer list.', 'invalid-layers');
}
if (migrated.layers.length > EDITOR_MAX_LAYERS) {
throw new EditorDocumentError(`Project exceeds the ${EDITOR_MAX_LAYERS}-layer limit.`, 'layer-budget');
}
let surfacePixels = width * height;
let embeddedCharacters = 0;
let recoveredId = 1;
const usedIds = new Set();
const layers = [];
for (let index = 0; index < migrated.layers.length; index += 1) {
const source = migrated.layers[index];
const label = typeof source?.name === 'string' && source.name.trim()
? source.name.trim().slice(0, 200)
: `Layer ${index + 1}`;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
warn(`${label} was skipped because its record is invalid.`);
continue;
}
const canvasW = positiveDimension(source.canvasW, width);
const canvasH = positiveDimension(source.canvasH, height);
if (!canvasW || !canvasH) {
warn(`${label} was skipped because its canvas dimensions are invalid.`);
continue;
}
const textIsValid = source.kind === 'text' && source.text && typeof source.text === 'object' && !Array.isArray(source.text);
const shapeIsValid = source.kind === 'shape' && source.shape && typeof source.shape === 'object' && !Array.isArray(source.shape);
const adjustmentIsValid = source.kind === 'adjustment' && source.adjustment && typeof source.adjustment === 'object' && !Array.isArray(source.adjustment);
const placed = source.kind === 'placed' ? normalizePlacedRecord(source.placed) : null;
const placedIsValid = source.kind === 'placed' && !!placed;
if (source.kind && source.kind !== 'raster' && source.kind !== 'text' && source.kind !== 'shape' && source.kind !== 'placed' && source.kind !== 'adjustment') {
warn(`${label} used unsupported layer type "${String(source.kind).slice(0, 40)}" and was recovered as raster pixels.`);
} else if (source.kind === 'text' && !textIsValid) {
warn(`${label} had invalid editable text metadata and was recovered as raster pixels.`);
} else if (source.kind === 'shape' && !shapeIsValid) {
warn(`${label} had invalid editable shape metadata and was recovered as raster pixels.`);
} else if (source.kind === 'adjustment' && !adjustmentIsValid) {
warn(`${label} had invalid adjustment metadata and was recovered as raster pixels.`);
} else if (source.kind === 'placed' && !placedIsValid) {
warn(`${label} had a missing or corrupt placed source and was recovered from its raster preview.`);
}
if (!isEmbeddedPng(source.dataUrl) && !textIsValid && !shapeIsValid && !placedIsValid && !adjustmentIsValid) {
warn(`${label} was skipped because its pixel data is missing or corrupt.`);
continue;
}
if (!isEmbeddedPng(source.dataUrl) && textIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its editable text data.`);
}
if (!isEmbeddedPng(source.dataUrl) && shapeIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its editable shape data.`);
}
if (!isEmbeddedPng(source.dataUrl) && placedIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its placed source.`);
}
embeddedCharacters += typeof source.dataUrl === 'string' ? source.dataUrl.length : 0;
surfacePixels += canvasW * canvasH;
if (placedIsValid) {
embeddedCharacters += placed.sourceDataUrl.length;
surfacePixels += placed.sourceWidth * placed.sourceHeight;
}
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `layer-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${label} received a replacement layer id.`);
}
usedIds.add(id);
const masks = [];
const maskRecords = Array.isArray(source.masks) ? source.masks : [];
if (maskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
warn(`${label} has more than ${EDITOR_MAX_MASKS_PER_LAYER} masks; extras were skipped.`);
}
for (let maskIndex = 0; maskIndex < Math.min(maskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
const mask = maskRecords[maskIndex];
const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Mask ${maskIndex + 1}`;
const maskW = positiveDimension(mask?.canvasW, width);
const maskH = positiveDimension(mask?.canvasH, height);
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
warn(`${label} / ${maskLabel} was skipped because its mask data is invalid.`);
continue;
}
embeddedCharacters += mask.dataUrl.length;
surfacePixels += maskW * maskH;
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
if (!maskId || usedIds.has(maskId)) {
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
warn(`${label} / ${maskLabel} received a replacement mask id.`);
}
usedIds.add(maskId);
const mode = mask.mode === 'layer' ? 'layer' : 'selection';
masks.push({
...mask,
id: maskId,
name: maskLabel,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode,
space: mask.space === 'layer' || mask.space === 'document'
? mask.space
: (mode === 'layer' ? 'layer' : 'document'),
linked: mode === 'layer' ? mask.linked !== false : true,
offset: mode === 'layer' ? {
x: finiteCoordinate(mask.offset?.x),
y: finiteCoordinate(mask.offset?.y),
} : { x: 0, y: 0 },
canvasW: maskW,
canvasH: maskH,
});
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const offset = source.offset && typeof source.offset === 'object' ? source.offset : {};
const opacity = Number(source.opacity);
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
if (source.blendMode && blendMode !== source.blendMode) {
warn(`${label} used unsupported blend mode "${String(source.blendMode).slice(0, 40)}" and was reset to Normal.`);
}
layers.push({
...source,
id,
name: label,
visible: source.visible !== false,
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
locked: !!source.locked,
locks: {
pixels: !!source.locks?.pixels,
transparency: !!source.locks?.transparency,
position: !!source.locks?.position,
},
clipped: !!source.clipped,
blendMode,
kind: textIsValid ? 'text' : (shapeIsValid ? 'shape' : (placedIsValid ? 'placed' : (adjustmentIsValid ? 'adjustment' : 'raster'))),
text: textIsValid ? source.text : null,
shape: shapeIsValid ? source.shape : null,
adjustment: adjustmentIsValid ? normalizeAdjustmentData(source.adjustment) : null,
effects: Array.isArray(source.effects) ? source.effects.map(normalizeEffect) : [],
placed: placedIsValid ? placed : null,
canvasW,
canvasH,
offset: { x: finiteCoordinate(offset.x), y: finiteCoordinate(offset.y) },
masks,
activeMaskId: masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
adjustments: source.adjustments && typeof source.adjustments === 'object' ? source.adjustments : {},
adjLayers: Array.isArray(source.adjLayers) ? source.adjLayers : [],
});
}
if (!layers.length) {
throw new EditorDocumentError('No recoverable layers were found in this project.', 'no-layers');
}
const activeLayerId = layers.some(layer => layer.id === migrated.activeLayerId)
? migrated.activeLayerId
: layers[layers.length - 1].id;
const groups = [];
const claimedLayerIds = new Set();
const validLayerIds = new Set(layers.map(layer => layer.id));
const groupRecords = Array.isArray(migrated.groups) ? migrated.groups : [];
if (groupRecords.length > EDITOR_MAX_GROUPS) {
warn(`Project has more than ${EDITOR_MAX_GROUPS} groups; extras were skipped.`);
}
for (let index = 0; index < Math.min(groupRecords.length, EDITOR_MAX_GROUPS); index += 1) {
const source = groupRecords[index];
if (!source || typeof source !== 'object' || Array.isArray(source)) {
warn(`Group ${index + 1} was skipped because its record is invalid.`);
continue;
}
const layerIds = [];
for (const id of Array.isArray(source.layerIds) ? source.layerIds : []) {
if (validLayerIds.has(id) && !claimedLayerIds.has(id)) {
claimedLayerIds.add(id);
layerIds.push(id);
}
}
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `group-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} received a replacement group id.`);
}
usedIds.add(id);
const groupMasks = [];
const groupMaskRecords = Array.isArray(source.masks) ? source.masks : [];
if (groupMaskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} has too many masks; extras were skipped.`);
}
for (let maskIndex = 0; maskIndex < Math.min(groupMaskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
const mask = groupMaskRecords[maskIndex];
const maskLabel = typeof mask?.name === 'string' && mask.name.trim()
? mask.name.trim().slice(0, 200)
: `Group Mask ${maskIndex + 1}`;
const maskW = positiveDimension(mask?.canvasW, width);
const maskH = positiveDimension(mask?.canvasH, height);
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} was skipped because its mask data is invalid.`);
continue;
}
embeddedCharacters += mask.dataUrl.length;
surfacePixels += maskW * maskH;
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
if (!maskId || usedIds.has(maskId)) {
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} received a replacement mask id.`);
}
usedIds.add(maskId);
groupMasks.push({
id: maskId,
name: maskLabel,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode: 'group',
space: 'document',
canvasW: maskW,
canvasH: maskH,
dataUrl: mask.dataUrl,
});
}
const opacity = Number(source.opacity);
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
if (source.blendMode && blendMode !== source.blendMode) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} used an unsupported blend mode and was reset to Normal.`);
}
groups.push({
id,
name: String(source.name || `Group ${index + 1}`).trim().slice(0, 200) || `Group ${index + 1}`,
layerIds,
parentId: typeof source.parentId === 'string' && source.parentId.trim()
? source.parentId.trim().slice(0, 100)
: null,
visible: source.visible !== false,
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
blendMode,
locked: !!source.locked,
collapsed: !!source.collapsed,
...(Array.isArray(source.effects) && source.effects.length
? { effects: source.effects.map(serializeEffect) }
: {}),
masks: groupMasks,
activeMaskId: groupMasks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
});
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const savedSelections = [];
const selectionRecords = Array.isArray(migrated.savedSelections) ? migrated.savedSelections : [];
if (selectionRecords.length > EDITOR_MAX_SAVED_SELECTIONS) {
warn(`Project has more than ${EDITOR_MAX_SAVED_SELECTIONS} saved selections; extras were skipped.`);
}
for (let index = 0; index < Math.min(selectionRecords.length, EDITOR_MAX_SAVED_SELECTIONS); index += 1) {
const source = selectionRecords[index];
const name = typeof source?.name === 'string' && source.name.trim()
? source.name.trim().slice(0, 100)
: `Selection ${index + 1}`;
if (!source || typeof source !== 'object' || source.canvasW !== width || source.canvasH !== height || !isEmbeddedPng(source.dataUrl)) {
warn(`${name} was skipped because its saved selection data is invalid or has the wrong dimensions.`);
continue;
}
embeddedCharacters += source.dataUrl.length;
surfacePixels += width * height;
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `selection-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${name} received a replacement selection id.`);
}
usedIds.add(id);
savedSelections.push({ id, name, canvasW: width, canvasH: height, dataUrl: source.dataUrl });
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers, masks, and saved selections exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const validGroupIds = new Set(groups.map(group => group.id));
const groupsById = new Map(groups.map(group => [group.id, group]));
for (const group of groups) {
if (group.parentId === group.id || (group.parentId && !validGroupIds.has(group.parentId))) {
warn(`${group.name} referenced a missing or invalid parent group and was moved to the top level.`);
group.parentId = null;
}
}
for (const group of groups) {
const visited = new Set([group.id]);
let cursor = group;
while (cursor.parentId) {
if (visited.has(cursor.parentId)) {
warn(`${group.name} contained a cyclic group relationship and was moved to the top level.`);
group.parentId = null;
break;
}
visited.add(cursor.parentId);
cursor = groupsById.get(cursor.parentId);
if (!cursor) break;
}
}
// A group may contain only child groups. Remove only empty leaves after the
// complete parent graph is available, then repeat for newly empty parents.
while (true) {
const parentIds = new Set(groups.map(group => group.parentId).filter(Boolean));
const emptyIndex = groups.findIndex(group => !group.layerIds.length && !parentIds.has(group.id));
if (emptyIndex < 0) break;
const [empty] = groups.splice(emptyIndex, 1);
warn(`${empty.name} was skipped because it has no recoverable layers or child groups.`);
}
const scopeByLayer = new Map();
for (const group of groups) {
for (const id of group.layerIds) scopeByLayer.set(id, group.id);
}
for (let index = 0; index < layers.length; index += 1) {
const layer = layers[index];
if (!layer.clipped) continue;
const previous = layers[index - 1];
const scope = scopeByLayer.get(layer.id) || null;
if (!previous || (scopeByLayer.get(previous.id) || null) !== scope) {
layer.clipped = false;
warn(`${layer.name} had no clipping base in its layer scope and was released.`);
}
}
if (storedVersion < EDITOR_DOCUMENT_VERSION) {
warnings.unshift(`Project upgraded from version ${storedVersion} to ${EDITOR_DOCUMENT_VERSION}.`);
}
return {
document: {
...migrated,
v: EDITOR_DOCUMENT_VERSION,
imgWidth: width,
imgHeight: height,
activeLayerId,
view: normalizeEditorView(migrated.view),
groups,
savedSelections,
layers,
},
warnings,
migratedFrom: storedVersion < EDITOR_DOCUMENT_VERSION ? storedVersion : null,
};
}
function canvasDataUrl(canvas) {
if (!canvas || typeof canvas.toDataURL !== 'function') return null;
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
function serializeEffect(effect) {
const normalized = normalizeEffect(effect);
if (!normalized.mask) return normalized;
return {
...normalized,
mask: {
id: normalized.mask.id,
name: normalized.mask.name,
visible: normalized.mask.visible !== false,
canvasW: normalized.mask.canvas?.width || normalized.mask.canvasW || 0,
canvasH: normalized.mask.canvas?.height || normalized.mask.canvasH || 0,
dataUrl: canvasDataUrl(normalized.mask.canvas),
},
};
}
export function serializeEditorDocument(state, extras = {}) {
return {
v: EDITOR_DOCUMENT_VERSION,
imageId: state.imageId || null,
imgWidth: state.imgWidth,
imgHeight: state.imgHeight,
activeLayerId: state.activeLayerId || null,
nextLayerId: state.nextLayerId,
view: normalizeEditorView(state),
...extras,
savedSelections: (state.savedSelections || []).slice(0, EDITOR_MAX_SAVED_SELECTIONS).map(selection => ({
id: selection.id,
name: String(selection.name || 'Selection').slice(0, 100),
canvasW: selection.canvas?.width || state.imgWidth || 0,
canvasH: selection.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(selection.canvas),
})),
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...(group.layerIds || [])],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: typeof group.opacity === 'number' ? group.opacity : 1,
blendMode: group.blendMode || 'source-over',
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
...(Array.isArray(group.effects) && group.effects.length
? { effects: group.effects.map(serializeEffect) }
: {}),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode: 'group',
space: 'document',
canvasW: mask.canvas?.width || state.imgWidth || 0,
canvasH: mask.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(mask.canvas),
})),
})),
layers: (state.layers || []).map(layer => ({
id: layer.id,
name: layer.name,
visible: layer.visible !== false,
opacity: typeof layer.opacity === 'number' ? layer.opacity : 1,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
isBase: !!layer.isBase,
blendMode: layer.blendMode || 'source-over',
kind: layer.kind || 'raster',
text: cloneDocumentValue(layer.text, null),
shape: cloneDocumentValue(layer.shape, null),
adjustment: cloneDocumentValue(layer.adjustment, null),
effects: Array.isArray(layer.effects) ? layer.effects.map(serializeEffect) : [],
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceWidth: layer.placed.sourceWidth || layer.placed.sourceCanvas.width,
sourceHeight: layer.placed.sourceHeight || layer.placed.sourceCanvas.height,
sourceName: String(layer.placed.sourceName || 'Placed image').slice(0, 200),
sourceDataUrl: canvasDataUrl(layer.placed.sourceCanvas),
matrix: Array.isArray(layer.placed.matrix) ? layer.placed.matrix.map(Number) : [1, 0, 0, 1, 0, 0],
} : null,
canvasW: layer.canvas?.width || 0,
canvasH: layer.canvas?.height || 0,
offset: { ...(state.layerOffsets?.get(layer.id) || { x: 0, y: 0 }) },
dataUrl: canvasDataUrl(layer.canvas),
adjustments: cloneDocumentValue(layer.adjustments, {}),
adjLayers: cloneDocumentValue(layer.adjLayers, []),
activeMaskId: layer.activeMaskId || null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
// Existing masks are AI/selection regions. A future true layer mask
// uses mode="layer" without changing old saved documents.
mode: mask.mode || 'selection',
space: mask.space || (mask.mode === 'layer' ? 'layer' : 'document'),
linked: mask.mode === 'layer' ? mask.linked !== false : true,
offset: mask.mode === 'layer'
? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y) }
: { x: 0, y: 0 },
canvasW: mask.canvas?.width || state.imgWidth || 0,
canvasH: mask.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(mask.canvas),
})),
})),
};
}
export function nextLayerIdFromDocument(data) {
const stored = Number(data?.nextLayerId);
if (Number.isInteger(stored) && stored > 0) return stored;
let max = 0;
for (const layer of data?.layers || []) {
const match = String(layer?.id || '').match(/(\d+)$/);
if (match) max = Math.max(max, Number(match[1]));
for (const mask of layer?.masks || []) {
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
}
}
for (const group of data?.groups || []) {
const match = String(group?.id || '').match(/(\d+)$/);
if (match) max = Math.max(max, Number(match[1]));
for (const mask of group?.masks || []) {
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
}
}
return max + 1;
}
+575
View File
@@ -0,0 +1,575 @@
/**
* Document-wide geometry operations.
*
* Layer pixels live in layer coordinates, while AI/selection masks live in
* document coordinates. Keeping those spaces explicit here prevents crop and
* resize commands from updating the visible pixels while leaving masks and
* selections behind.
*/
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, -x, -y);
return out;
}
function renderTranslated(source, width, height, x, y) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, x, y);
return out;
}
function renderScaled(source, width, height, smoothingQuality = 'high') {
const out = makeCanvas(width, height);
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = smoothingQuality;
ctx.drawImage(source, 0, 0, out.width, out.height);
return out;
}
function renderRotated(source, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
const swap = normalized === 90 || normalized === 270;
const out = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
const ctx = out.getContext('2d');
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate((normalized * Math.PI) / 180);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
return out;
}
function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d');
ctx.save();
if (axis === 'h') {
ctx.translate(out.width, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, out.height);
ctx.scale(1, -1);
}
ctx.drawImage(source, 0, 0);
ctx.restore();
return out;
}
function replaceCanvas(holder, rendered) {
holder.canvas.width = rendered.width;
holder.canvas.height = rendered.height;
holder.ctx = holder.canvas.getContext('2d');
holder.ctx.clearRect(0, 0, rendered.width, rendered.height);
holder.ctx.drawImage(rendered, 0, 0);
}
function replaceStandaloneCanvas(canvas, rendered) {
canvas.width = rendered.width;
canvas.height = rendered.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, rendered.width, rendered.height);
ctx.drawImage(rendered, 0, 0);
}
function invalidateLayerCaches(layer) {
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._stagedAdj = null;
layer._editingAdjId = null;
}
function syncActiveMask(editorState) {
const activeGroup = (editorState.layerGroups || []).find(group => group.id === editorState.activeGroupId);
const groupMask = activeGroup?.masks?.find(item => item.id === activeGroup.activeMaskId) || null;
if (groupMask) {
editorState.maskCanvas = groupMask.canvas;
editorState.maskCtx = groupMask.ctx;
return;
}
const active = editorState.layers.find(layer => layer.id === editorState.activeLayerId);
const mask = active?.masks?.find(item => item.id === active.activeMaskId) || null;
editorState.maskCanvas = mask?.canvas || null;
editorState.maskCtx = mask?.ctx || null;
}
function syncDocumentCanvas(editorState, width, height) {
editorState.imgWidth = width;
editorState.imgHeight = height;
if (editorState.mainCanvas) {
editorState.mainCanvas.width = width;
editorState.mainCanvas.height = height;
editorState.mainCtx = editorState.mainCanvas.getContext('2d');
}
editorState.documentCompositeCanvas = null;
editorState.compositeMaskUnion = null;
editorState.wandSrcCache = null;
syncActiveMask(editorState);
}
function normalizedGuideValues(values, limit, mapper = value => value) {
const output = [];
for (const raw of Array.isArray(values) ? values : []) {
const value = mapper(Number(raw));
if (!Number.isFinite(value) || value < 0 || value > limit) continue;
if (!output.some(existing => Math.abs(existing - value) < 0.0001)) output.push(value);
}
return output.sort((a, b) => a - b);
}
function transformGuides(editorState, vertical, horizontal) {
editorState.guides = {
vertical: normalizedGuideValues(vertical.values, vertical.limit, vertical.map),
horizontal: normalizedGuideValues(horizontal.values, horizontal.limit, horizontal.map),
};
}
function clipPolygonAxis(points, inside, intersect) {
if (!points.length) return [];
const output = [];
let previous = points[points.length - 1];
let previousInside = inside(previous);
for (const current of points) {
const currentInside = inside(current);
if (currentInside !== previousInside) output.push(intersect(previous, current));
if (currentInside) output.push(current);
previous = current;
previousInside = currentInside;
}
return output;
}
/** Clip a polygon to a document rectangle using Sutherland-Hodgman. */
export function clipPolygonToRect(points, width, height) {
let out = (points || []).map(point => ({ x: point.x, y: point.y }));
const xAt = (value) => (a, b) => {
const dx = b.x - a.x;
const t = dx === 0 ? 0 : (value - a.x) / dx;
return { x: value, y: a.y + (b.y - a.y) * t };
};
const yAt = (value) => (a, b) => {
const dy = b.y - a.y;
const t = dy === 0 ? 0 : (value - a.y) / dy;
return { x: a.x + (b.x - a.x) * t, y: value };
};
out = clipPolygonAxis(out, p => p.x >= 0, xAt(0));
out = clipPolygonAxis(out, p => p.x <= width, xAt(width));
out = clipPolygonAxis(out, p => p.y >= 0, yAt(0));
out = clipPolygonAxis(out, p => p.y <= height, yAt(height));
return out.length >= 3 ? out : [];
}
function normalizeCropRect(editorState, rect) {
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
return { x, y, width, height };
}
function forEachStoredSelection(editorState, callback) {
for (const selection of editorState.savedSelections || []) {
if (selection?.canvas) callback(selection.canvas);
}
if (editorState.lastSelection?.canvas) callback(editorState.lastSelection.canvas);
}
/** Crop layer pixels, masks, and transient selections as one document. */
export function cropDocument(editorState, rect) {
if (!editorState.imgWidth || !editorState.imgHeight) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const { x, y, width, height } = normalizeCropRect(editorState, rect);
const oldOffsets = new Map(editorState.layerOffsets);
for (const layer of editorState.layers) {
const offset = oldOffsets.get(layer.id) || { x: 0, y: 0 };
const retainedText = layer.kind === 'text' && layer.text;
const retainedShape = layer.kind === 'shape' && layer.shape;
let placedOffset = null;
if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [1, 0, 0, 1, -x, -y]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else if (!retainedText && !retainedShape) {
replaceCanvas(layer, renderWindow(layer.canvas, x - offset.x, y - offset.y, width, height));
}
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if ((retainedText || retainedShape || layer.kind === 'placed') && !documentSpace) continue;
const maskOffset = mask.offset || { x: 0, y: 0 };
const sourceX = documentSpace ? x : x - offset.x - (Number(maskOffset.x) || 0);
const sourceY = documentSpace ? y : y - offset.y - (Number(maskOffset.y) || 0);
replaceCanvas(mask, renderWindow(mask.canvas, sourceX, sourceY, width, height));
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) mask.offset = { x: 0, y: 0 };
}
editorState.layerOffsets.set(layer.id, placedOffset || ((retainedText || retainedShape)
? { x: offset.x - x, y: offset.y - y }
: { x: 0, y: 0 }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderWindow(mask.canvas, x, y, width, height));
}
if (editorState.wandMask) {
const sourceLayerOffset = oldOffsets.get(editorState.wandLayerId) || { x: 0, y: 0 };
const documentSpace = editorState.wandMaskSpace === 'document' || (
!editorState.wandMaskSpace && editorState.wandMask.width === oldWidth && editorState.wandMask.height === oldHeight
);
const sourceX = documentSpace ? x : x - sourceLayerOffset.x;
const sourceY = documentSpace ? y : y - sourceLayerOffset.y;
replaceStandaloneCanvas(
editorState.wandMask,
renderWindow(editorState.wandMask, sourceX, sourceY, width, height),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderWindow(canvas, x, y, width, height));
});
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x - x, y: editorState.wandLastSeed.y - y };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < width && seed.y < height ? seed : null;
}
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x - x, y: point.y - y })),
width,
height,
);
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: width, map: value => value - x },
{ values: guides.horizontal, limit: height, map: value => value - y },
);
editorState.cropRect = null;
editorState.cropStart = null;
editorState.cropEnd = null;
syncDocumentCanvas(editorState, width, height);
return { x, y, width, height };
}
/** Resize the actual image content, including every layer-space/document-space mask. */
export function resizeImageDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
if (!oldWidth || !oldHeight) return null;
const scaleX = newWidth / oldWidth;
const scaleY = newHeight / oldHeight;
const quality = options.smoothingQuality || 'high';
for (const layer of editorState.layers) {
const targetLayerWidth = Math.max(1, Math.round(layer.canvas.width * scaleX));
const targetLayerHeight = Math.max(1, Math.round(layer.canvas.height * scaleY));
let placedOffset = null;
if (layer.kind === 'text' && layer.text) scaleTextLayer(layer, scaleX, scaleY);
else if (layer.kind === 'shape' && layer.shape) scaleShapeLayer(layer, scaleX, scaleY);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [scaleX, 0, 0, scaleY, 0, 0]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderScaled(layer.canvas, targetLayerWidth, targetLayerHeight, quality));
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
const targetMaskWidth = documentSpace
? newWidth
: Math.max(1, Math.round(mask.canvas.width * scaleX));
const targetMaskHeight = documentSpace
? newHeight
: Math.max(1, Math.round(mask.canvas.height * scaleY));
replaceCanvas(
mask,
renderScaled(mask.canvas, targetMaskWidth, targetMaskHeight, quality),
);
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) {
mask.offset = {
x: Math.round((Number(mask.offset?.x) || 0) * scaleX),
y: Math.round((Number(mask.offset?.y) || 0) * scaleY),
};
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(offset.x * scaleX),
y: Math.round(offset.y * scaleY),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderScaled(mask.canvas, newWidth, newHeight, quality));
}
if (editorState.wandMask) {
replaceStandaloneCanvas(
editorState.wandMask,
renderScaled(
editorState.wandMask,
editorState.wandMaskSpace === 'document' ? newWidth : Math.max(1, Math.round(editorState.wandMask.width * scaleX)),
editorState.wandMaskSpace === 'document' ? newHeight : Math.max(1, Math.round(editorState.wandMask.height * scaleY)),
quality,
),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderScaled(canvas, newWidth, newHeight, quality));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: editorState.wandLastSeed.x * scaleX,
y: editorState.wandLastSeed.y * scaleY,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: point.x * scaleX,
y: point.y * scaleY,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value * scaleX },
{ values: guides.horizontal, limit: newHeight, map: value => value * scaleY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, scaleX, scaleY };
}
/** Change document bounds without resampling layer pixels. Origin stays top-left. */
export function resizeCanvasDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const anchorX = Math.max(0, Math.min(1, Number(options.anchorX) || 0));
const anchorY = Math.max(0, Math.min(1, Number(options.anchorY) || 0));
const shiftX = Math.round((newWidth - editorState.imgWidth) * anchorX);
const shiftY = Math.round((newHeight - editorState.imgHeight) * anchorY);
for (const layer of editorState.layers) {
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if (!documentSpace) continue;
replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
mask.space = 'document';
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, {
x: (Number(offset.x) || 0) + shiftX,
y: (Number(offset.y) || 0) + shiftY,
});
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
}
if (editorState.wandMask && editorState.wandMaskSpace === 'document') {
replaceStandaloneCanvas(editorState.wandMask, renderTranslated(editorState.wandMask, newWidth, newHeight, shiftX, shiftY));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderTranslated(canvas, newWidth, newHeight, shiftX, shiftY));
});
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x + shiftX, y: point.y + shiftY })),
newWidth,
newHeight,
);
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x + shiftX, y: editorState.wandLastSeed.y + shiftY };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < newWidth && seed.y < newHeight ? seed : null;
}
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value + shiftX },
{ values: guides.horizontal, limit: newHeight, map: value => value + shiftY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight };
}
function rotatedPoint(point, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - point.y, y: point.x };
if (normalized === 180) return { x: oldWidth - point.x, y: oldHeight - point.y };
if (normalized === 270) return { x: point.y, y: oldWidth - point.x };
return { x: point.x, y: point.y };
}
function rotatedOffset(offset, width, height, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - offset.y - height, y: offset.x };
if (normalized === 180) return { x: oldWidth - offset.x - width, y: oldHeight - offset.y - height };
if (normalized === 270) return { x: offset.y, y: oldWidth - offset.x - width };
return { x: offset.x, y: offset.y };
}
/** Rotate every layer, mask, offset, and active selection by a right angle. */
export function rotateDocument(editorState, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (![90, 180, 270].includes(normalized)) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = normalized === 180 ? oldWidth : oldHeight;
const newHeight = normalized === 180 ? oldHeight : oldWidth;
for (const layer of editorState.layers) {
const oldLayerWidth = layer.canvas.width;
const oldLayerHeight = layer.canvas.height;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
let placedOffset = null;
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = normalized === 90
? [0, 1, -1, 0, oldHeight, 0]
: normalized === 180
? [-1, 0, 0, -1, oldWidth, oldHeight]
: [0, -1, 1, 0, 0, oldWidth];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderRotated(layer.canvas, normalized));
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: oldLayerHeight - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: oldLayerWidth - ox - oldMaskWidth, y: oldLayerHeight - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: oldLayerWidth - ox - oldMaskWidth };
}
}
}
editorState.layerOffsets.set(layer.id, placedOffset ||
rotatedOffset(offset, oldLayerWidth, oldLayerHeight, normalized, oldWidth, oldHeight));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderRotated(mask.canvas, normalized));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderRotated(editorState.wandMask, normalized));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotated(canvas, normalized));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
...rotatedPoint(editorState.wandLastSeed, normalized, oldWidth, oldHeight),
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point =>
rotatedPoint(point, normalized, oldWidth, oldHeight));
const guides = editorState.guides || {};
if (normalized === 90) {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth, map: value => oldHeight - value },
{ values: guides.vertical, limit: newHeight },
);
} else if (normalized === 180) {
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => oldWidth - value },
{ values: guides.horizontal, limit: newHeight, map: value => oldHeight - value },
);
} else {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth },
{ values: guides.vertical, limit: newHeight, map: value => oldWidth - value },
);
}
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
for (const layer of editorState.layers) {
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
let placedOffset = null;
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = axis === 'h'
? [-1, 0, 0, 1, width, 0]
: [1, 0, 0, -1, 0, height];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderFlipped(layer.canvas, axis));
for (const mask of layer.masks || []) {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: layerWidth - ox - maskWidth, y: oy }
: { x: ox, y: layerHeight - oy - maskHeight };
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || (axis === 'h'
? { x: width - offset.x - layerWidth, y: offset.y }
: { x: offset.x, y: height - offset.y - layerHeight }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderFlipped(mask.canvas, axis));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderFlipped(editorState.wandMask, axis));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderFlipped(canvas, axis));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: axis === 'h' ? width - editorState.wandLastSeed.x : editorState.wandLastSeed.x,
y: axis === 'v' ? height - editorState.wandLastSeed.y : editorState.wandLastSeed.y,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: axis === 'h' ? width - point.x : point.x,
y: axis === 'v' ? height - point.y : point.y,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{
values: guides.vertical,
limit: width,
map: axis === 'h' ? value => width - value : value => value,
},
{
values: guides.horizontal,
limit: height,
map: axis === 'v' ? value => height - value : value => value,
},
);
syncDocumentCanvas(editorState, width, height);
return { width, height, axis };
}
+117
View File
@@ -0,0 +1,117 @@
/* 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);
out.getContext('2d').drawImage(source, 0, 0);
return out;
}
function sharpenCanvas(source, amount) {
if (!amount) return copyCanvas(source);
const out = new OffscreenCanvas(source.width, source.height);
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
const dst = out.getContext('2d').createImageData(source.width, source.height);
const a = Math.max(0, Math.min(1, Number(amount) || 0));
const stride = source.width * 4;
for (let y = 0; y < source.height; y += 1) {
for (let x = 0; x < source.width; x += 1) {
const at = (y * source.width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
const center = src.data[at + channel] * (1 + 4 * a);
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
}
dst.data[at + 3] = src.data[at + 3];
}
}
out.getContext('2d').putImageData(dst, 0, 0);
return out;
}
function maskedContribution(effectCanvas, mask) {
if (!mask) return effectCanvas;
const out = new OffscreenCanvas(effectCanvas.width, effectCanvas.height);
const ctx = out.getContext('2d');
ctx.drawImage(effectCanvas, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(mask, 0, 0, out.width, out.height);
ctx.globalCompositeOperation = 'source-over';
return out;
}
function gradientColor(value, alpha = 1) {
const raw = String(value || '').replace(/^#/, '');
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
return `rgba(${channels.join(',')},${alpha})`;
}
function renderEffects(source, effects, masks) {
let current = copyCanvas(source);
for (let index = 0; index < effects.length; index += 1) {
const effect = effects[index];
if (effect.visible === false || effect.opacity <= 0) continue;
const next = new OffscreenCanvas(current.width, current.height);
const ctx = next.getContext('2d');
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';
} else if (effect.type === 'sharpen') {
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
ctx.globalCompositeOperation = effect.params.blendMode;
ctx.fillStyle = effect.params.color;
ctx.fillRect(0, 0, next.width, next.height);
} else if (effect.type === 'drop-shadow') {
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.width;
ctx.drawImage(current, 0, 0);
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
const p = effect.params;
const gradient = effect.type === 'radial-gradient'
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
for (const stop of p.stops || []) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
ctx.globalAlpha = p.opacity;
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, next.width, next.height);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.drawImage(current, 0, 0);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
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;
current = blended;
}
return current;
}
self.onmessage = event => {
try {
const { source, effects, masks } = event.data;
const output = renderEffects(source, effects || [], masks || []);
const bitmap = output.transferToImageBitmap();
self.postMessage({ bitmap }, [bitmap]);
} catch (error) {
self.postMessage({ error: String(error?.message || error) });
}
};
+333
View File
@@ -0,0 +1,333 @@
/** 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 } },
'crisp-detail': { type: 'sharpen', params: { amount: 0.35 } },
'soft-shadow': { type: 'drop-shadow', params: { color: '#000000', opacity: 0.3, blur: 8, x: 2, y: 3 } },
'white-outline': { type: 'stroke', params: { color: '#ffffff', opacity: 0.85, width: 2 } },
};
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeEffectMask(mask) {
if (!mask || typeof mask !== 'object' || Array.isArray(mask)) return null;
return {
id: String(mask.id || `effect-mask-${Math.random().toString(36).slice(2, 9)}`),
name: String(mask.name || 'Effect Mask'),
visible: mask.visible !== false,
canvas: mask.canvas || null,
ctx: mask.ctx || null,
canvasW: Number(mask.canvasW) || mask.canvas?.width || 0,
canvasH: Number(mask.canvasH) || mask.canvas?.height || 0,
dataUrl: typeof mask.dataUrl === 'string' ? mask.dataUrl : null,
};
}
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' };
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
if (type === 'linear-gradient' || type === 'radial-gradient') return {
x1: 0, y1: 0, x2: 1, y2: 0,
stops: [{ position: 0, color: '#e06c75', alpha: 1 }, { position: 100, color: '#ffffff', alpha: 1 }],
opacity: 1,
};
return {};
}
export function effectPreset(name) {
const preset = EFFECT_PRESETS[name];
return preset ? { type: preset.type, params: clone(preset.params) } : null;
}
export function normalizeEffect(effect) {
const type = EFFECT_TYPES.has(effect?.type) ? effect.type : 'gaussian-blur';
const defaults = defaultEffectParams(type);
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') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
}
if (type === 'drop-shadow') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
params.opacity = Math.max(0, Math.min(1, params.opacity));
params.blur = Math.max(0, Math.min(200, params.blur));
params.x = Math.max(-200, Math.min(200, params.x));
params.y = Math.max(-200, Math.min(200, params.y));
}
if (type === 'stroke') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
params.width = Math.max(0, Math.min(100, Number(params.width) || 0));
}
if (type === 'linear-gradient' || type === 'radial-gradient') {
for (const key of ['x1', 'y1', 'x2', 'y2']) {
const value = Number(params[key]);
params[key] = Number.isFinite(value) ? Math.max(-2_000_000, Math.min(2_000_000, value)) : defaults[key];
}
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
const stops = Array.isArray(params.stops) ? params.stops : defaults.stops;
params.stops = stops.map(stop => ({
position: Math.max(0, Math.min(100, Number(stop?.position) || 0)),
color: /^#[0-9a-f]{6}$/i.test(stop?.color) ? stop.color : '#000000',
alpha: Math.max(0, Math.min(1, Number(stop?.alpha ?? 1) || 0)),
})).sort((a, b) => a.position - b.position).slice(0, 12);
if (params.stops.length < 2) params.stops = clone(defaults.stops);
}
return {
id: String(effect?.id || `effect-${Math.random().toString(36).slice(2, 9)}`),
type,
name: String(effect?.name || effectLabel(type)),
visible: effect?.visible !== false,
opacity: Math.max(0, Math.min(1, Number(effect?.opacity ?? 1) || 0)),
mask: normalizeEffectMask(effect?.mask),
params,
};
}
export function effectLabel(type) {
if (LAYER_STYLES[type]) return LAYER_STYLES[type].label;
return {
'gaussian-blur': 'Gaussian Blur',
sharpen: 'Sharpen',
'color-overlay': 'Color Overlay',
'drop-shadow': 'Drop Shadow',
'stroke': 'Stroke',
'linear-gradient': 'Gradient',
'radial-gradient': 'Radial Gradient',
}[type] || type;
}
function copyCanvas(source) {
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
function gradientColor(value, alpha = 1) {
const raw = String(value || '').replace(/^#/, '');
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
return `rgba(${channels.join(',')},${alpha})`;
}
function renderGradient(ctx, effect, width, height) {
const p = effect.params;
const gradient = effect.type === 'radial-gradient'
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
for (const stop of p.stops) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
ctx.globalAlpha = p.opacity;
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
}
function applyEffectMask(source, effected, mask) {
if (!mask?.canvas || !source || !effected) return effected;
const masked = document.createElement('canvas');
masked.width = effected.width;
masked.height = effected.height;
const mctx = masked.getContext('2d');
mctx.drawImage(effected, 0, 0);
mctx.globalCompositeOperation = 'destination-in';
mctx.drawImage(mask.canvas, 0, 0, masked.width, masked.height);
mctx.globalCompositeOperation = 'source-over';
return masked;
}
function sharpenCanvas(source, amount, shouldContinue = () => true) {
if (!amount) return copyCanvas(source);
const out = document.createElement('canvas');
out.width = source.width;
out.height = source.height;
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
const dst = out.getContext('2d').createImageData(source.width, source.height);
const a = Math.max(0, Math.min(1, Number(amount) || 0));
const stride = source.width * 4;
for (let y = 0; y < source.height; y += 1) {
if ((y & 7) === 0 && !shouldContinue()) return null;
for (let x = 0; x < source.width; x += 1) {
const at = (y * source.width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
const center = src.data[at + channel] * (1 + 4 * a);
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
}
dst.data[at + 3] = src.data[at + 3];
}
}
out.getContext('2d').putImageData(dst, 0, 0);
return out;
}
/** Render effects in list order without mutating the source layer. */
export function renderEffects(source, effects = [], shouldContinue = () => true) {
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
let current = copyCanvas(source);
for (const raw of effects) {
if (!shouldContinue()) return current;
const effect = normalizeEffect(raw);
if (!effect.visible || effect.opacity <= 0) continue;
const next = document.createElement('canvas');
next.width = current.width;
next.height = current.height;
const ctx = next.getContext('2d');
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';
} else if (effect.type === 'sharpen') {
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
if (!sharpened) return current;
ctx.drawImage(sharpened, 0, 0);
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
ctx.globalCompositeOperation = effect.params.blendMode;
ctx.fillStyle = effect.params.color;
ctx.fillRect(0, 0, next.width, next.height);
ctx.globalAlpha = 1;
} else if (effect.type === 'drop-shadow') {
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.width;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.drawImage(current, 0, 0);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = 1;
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
renderGradient(ctx, effect, next.width, next.height);
}
ctx.globalCompositeOperation = 'source-over';
const contribution = effect.mask?.canvas && effect.mask.visible !== false
? applyEffectMask(current, next, effect.mask)
: 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;
current = blended;
}
return current;
}
/**
* Rasterize retained effects off the main thread when browser canvas workers
* are available. The synchronous renderer remains the compatibility fallback.
*/
export async function renderEffectsAsync(source, effects = [], shouldContinue = () => true) {
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
return renderEffects(source, effects, shouldContinue);
}
if (!shouldContinue()) return source;
let sourceBitmap;
const maskBitmaps = [];
try {
sourceBitmap = await createImageBitmap(source);
for (const effect of effects) {
if (!shouldContinue()) {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return null;
}
maskBitmaps.push(effect?.mask?.canvas && effect.mask.visible !== false
? await createImageBitmap(effect.mask.canvas)
: null);
}
} catch {
sourceBitmap?.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
}
if (!shouldContinue()) {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return source;
}
let worker;
try {
worker = new Worker(new URL('./effects-worker.js', import.meta.url), { type: 'module' });
} catch {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
}
const payloadEffects = effects.map(raw => {
const effect = normalizeEffect(raw);
return { ...effect, mask: null };
});
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
worker.terminate();
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
resolve(result);
};
worker.onmessage = event => {
const { bitmap, error } = event.data || {};
if (error || !bitmap || !shouldContinue()) {
bitmap?.close?.();
finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : source);
return;
}
const output = document.createElement('canvas');
output.width = source.width;
output.height = source.height;
output.getContext('2d').drawImage(bitmap, 0, 0);
bitmap.close?.();
finish(output);
};
// A stale worker must not trigger a full-resolution fallback while a newer
// preview is already queued.
worker.onerror = () => finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : null);
worker.postMessage({ source: sourceBitmap, effects: payloadEffects, masks: maskBitmaps }, [
sourceBitmap,
...maskBitmaps.filter(Boolean),
]);
});
}
export const EFFECT_TYPES_LIST = [...EFFECT_TYPES];
+221
View File
@@ -0,0 +1,221 @@
/** Controlled image export dialog and canvas encoding helpers. */
const FORMAT_META = {
png: { mime: 'image/png', extension: 'png', quality: undefined },
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
};
function clampNumber(value, min, max, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
export function normalizeExportSettings(settings, sourceWidth, sourceHeight) {
const format = FORMAT_META[settings?.format] ? settings.format : 'png';
return {
format,
width: Math.round(clampNumber(settings?.width, 1, 16384, sourceWidth)),
height: Math.round(clampNumber(settings?.height, 1, 16384, sourceHeight)),
quality: clampNumber(settings?.quality, 0.01, 1, FORMAT_META[format].quality ?? 1),
transparency: format !== 'jpeg' && settings?.transparency !== false,
matte: /^#[0-9a-f]{6}$/i.test(settings?.matte || '') ? settings.matte : '#ffffff',
filename: String(settings?.filename || 'edited-image').trim() || 'edited-image',
};
}
export function prepareExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const canvas = document.createElement('canvas');
canvas.width = normalized.width;
canvas.height = normalized.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
if (!normalized.transparency) {
ctx.fillStyle = normalized.matte;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
return canvas;
}
export function encodeExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const output = prepareExportCanvas(sourceCanvas, normalized);
const meta = FORMAT_META[normalized.format];
return new Promise((resolve, reject) => {
output.toBlob(
blob => blob ? resolve({ blob, settings: normalized, extension: meta.extension }) : reject(new Error('Image encoding failed')),
meta.mime,
meta.quality == null ? undefined : normalized.quality,
);
});
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return 'Estimating...';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function openExportDialog({
sourceCanvas,
defaultName = 'edited-image',
title = 'Export Image',
submitLabel = 'Export',
attachColorPicker = null,
returnFocus = null,
}) {
return new Promise(resolve => {
const previouslyFocused = returnFocus || document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'ge-export-overlay';
overlay.innerHTML = `
<form class="ge-export-dialog" role="dialog" aria-modal="true" aria-labelledby="ge-export-title">
<header class="ge-export-head">
<h2 id="ge-export-title">${String(title).replace(/[<>&]/g, '')}</h2>
<button type="button" class="ge-export-close" aria-label="Close">&times;</button>
</header>
<div class="ge-export-body">
<div class="ge-export-preview-wrap"><canvas class="ge-export-preview"></canvas></div>
<div class="ge-export-fields">
<label class="ge-export-field"><span>Filename</span><input id="ge-export-filename" type="text" value="${String(defaultName).replace(/[<>&"']/g, '')}" /></label>
<div class="ge-export-field"><span>Format</span><div class="ge-export-format" role="group" aria-label="Export format">
<button type="button" class="active" data-format="png">PNG</button>
<button type="button" data-format="jpeg">JPEG</button>
<button type="button" data-format="webp">WebP</button>
</div></div>
<div class="ge-export-dimensions">
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
<button type="button" class="ge-export-link active" aria-label="Lock aspect ratio" title="Lock aspect ratio" aria-pressed="true">
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<label class="ge-export-field"><span>Height</span><input id="ge-export-height" type="number" min="1" max="16384" value="${sourceCanvas.height}" /></label>
</div>
<label class="ge-export-field ge-export-quality"><span>Quality <output>90%</output></span><input id="ge-export-quality" type="range" min="1" max="100" value="90" /></label>
<div class="ge-export-surface-row">
<label class="ge-export-toggle"><input id="ge-export-transparency" type="checkbox" checked /><span>Transparency</span></label>
<label class="ge-export-matte"><span>Matte</span><input id="ge-export-matte" class="ge-color-picker" type="color" value="#ffffff" /></label>
</div>
</div>
</div>
<footer class="ge-export-footer"><span class="ge-export-estimate">Estimating...</span><div><button type="button" class="ge-btn ge-btn-sm ge-export-cancel">Cancel</button><button type="submit" class="ge-btn ge-btn-primary">${String(submitLabel).replace(/[<>&]/g, '')}</button></div></footer>
</form>`;
document.body.appendChild(overlay);
const dialog = overlay.querySelector('.ge-export-dialog');
const preview = overlay.querySelector('.ge-export-preview');
const filename = overlay.querySelector('#ge-export-filename');
const widthInput = overlay.querySelector('#ge-export-width');
const heightInput = overlay.querySelector('#ge-export-height');
const qualityInput = overlay.querySelector('#ge-export-quality');
const qualityField = overlay.querySelector('.ge-export-quality');
const qualityOutput = qualityField.querySelector('output');
const transparency = overlay.querySelector('#ge-export-transparency');
const matte = overlay.querySelector('#ge-export-matte');
const matteField = overlay.querySelector('.ge-export-matte');
const estimate = overlay.querySelector('.ge-export-estimate');
const link = overlay.querySelector('.ge-export-link');
const ratio = sourceCanvas.width / sourceCanvas.height;
let format = 'png';
let ratioLocked = true;
let estimateTimer = null;
let estimateVersion = 0;
try { attachColorPicker?.(matte); } catch {}
const settings = () => normalizeExportSettings({
format,
width: widthInput.value,
height: heightInput.value,
quality: Number(qualityInput.value) / 100,
transparency: transparency.checked,
matte: matte.value,
filename: filename.value,
}, sourceCanvas.width, sourceCanvas.height);
const renderPreview = () => {
const current = settings();
const max = 420;
const scale = Math.min(max / current.width, max / current.height, 1);
preview.width = Math.max(1, Math.round(current.width * scale));
preview.height = Math.max(1, Math.round(current.height * scale));
const ctx = preview.getContext('2d');
// Settings can change without changing dimensions, so do not let the
// previous matte or alpha state bleed into the next preview.
ctx.clearRect(0, 0, preview.width, preview.height);
if (!current.transparency) {
ctx.fillStyle = current.matte;
ctx.fillRect(0, 0, preview.width, preview.height);
}
ctx.drawImage(sourceCanvas, 0, 0, preview.width, preview.height);
};
const update = () => {
const lossy = format !== 'png';
qualityField.hidden = !lossy;
transparency.disabled = format === 'jpeg';
if (format === 'jpeg') transparency.checked = false;
matteField.classList.toggle('disabled', transparency.checked && format !== 'jpeg');
qualityOutput.textContent = `${qualityInput.value}%`;
renderPreview();
estimate.textContent = 'Estimating...';
clearTimeout(estimateTimer);
const version = ++estimateVersion;
estimateTimer = setTimeout(async () => {
try {
const encoded = await encodeExportCanvas(sourceCanvas, settings());
if (version === estimateVersion) estimate.textContent = formatBytes(encoded.blob.size);
} catch {
if (version === estimateVersion) estimate.textContent = 'Estimate unavailable';
}
}, 180);
};
overlay.querySelectorAll('[data-format]').forEach(button => {
button.addEventListener('click', () => {
format = button.dataset.format;
overlay.querySelectorAll('[data-format]').forEach(candidate => {
candidate.classList.toggle('active', candidate.dataset.format === format);
});
update();
});
});
link.addEventListener('click', () => {
ratioLocked = !ratioLocked;
link.classList.toggle('active', ratioLocked);
link.setAttribute('aria-pressed', ratioLocked ? 'true' : 'false');
});
widthInput.addEventListener('input', () => {
if (ratioLocked) heightInput.value = String(Math.max(1, Math.round(Number(widthInput.value || 1) / ratio)));
update();
});
heightInput.addEventListener('input', () => {
if (ratioLocked) widthInput.value = String(Math.max(1, Math.round(Number(heightInput.value || 1) * ratio)));
update();
});
[qualityInput, transparency, matte].forEach(input => input.addEventListener('input', update));
const close = result => {
clearTimeout(estimateTimer);
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus({ preventScroll: true });
}
resolve(result);
};
const onKey = event => {
if (event.key === 'Escape') { event.preventDefault(); close(null); }
};
document.addEventListener('keydown', onKey, true);
overlay.querySelector('.ge-export-close').addEventListener('click', () => close(null));
overlay.querySelector('.ge-export-cancel').addEventListener('click', () => close(null));
overlay.addEventListener('click', event => { if (event.target === overlay) close(null); });
dialog.addEventListener('submit', event => { event.preventDefault(); close(settings()); });
update();
filename.select();
});
}
+383 -21
View File
@@ -43,11 +43,13 @@ import modalManager from '../../modalManager.js';
import {
ADJ_ICONS,
adjLayerLabel,
adjustmentPresetOptions,
adjustmentPresetParams,
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
@@ -104,6 +106,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
}
function openFxPopup(layer, anchorEl) {
if (layer?.kind === 'adjustment') {
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
return;
}
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
// `state` is a shared singleton that survives editor close/reopen,
// so a stale `fxMenuEl` from a previous session (whose detached
@@ -135,9 +141,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
menu.style.pointerEvents = 'auto';
const items = [
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
{ type: 'exposure', label: 'Exposure' },
{ type: 'white-balance', label: 'White Balance' },
{ type: 'hue-saturation', label: 'Hue / Saturation' },
{ type: 'vibrance', label: 'Vibrance' },
{ type: 'black-white', label: 'Black & White' },
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
{ type: 'levels', label: 'Levels' },
{ type: 'curves', label: 'Curves' },
{ type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' },
];
menu.innerHTML = items.map(i =>
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
@@ -257,7 +271,21 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
suppressLayerGhostTap();
const layer = state.adjPopupEl._layer;
if (layer) {
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
layer._adjPreview = false;
layer._adjCompare = false;
if (layer._stagedAdj) layer._stagedAdj = null;
if (layer._newAdjustmentLayer) {
const index = state.layers.findIndex(item => item.id === layer.id);
if (index >= 0) state.layers.splice(index, 1);
state.layerOffsets.delete(layer.id);
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
state.undoStack.length = layer._newAdjustmentUndoLength;
}
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
layer._newAdjustmentLayer = false;
renderLayerPanel();
}
if (layer._editingAdjId) layer._editingAdjId = null;
layer._adjFinalKey = null;
composite();
@@ -277,11 +305,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
closeAdjPopup();
// Editing an existing sub-layer? Pre-load its params as the staged
// preview and mark the popup so Apply updates instead of appending.
const firstClass = layer?.kind === 'adjustment';
const editing = !!existingAdj;
const presetOptions = adjustmentPresetOptions(type);
const startParams = editing
? JSON.parse(JSON.stringify(existingAdj.params))
: defaultAdjParams(type);
layer._stagedAdj = { type, params: startParams };
layer._adjPreview = true;
state.adjPreviewLayer = layer;
if (editing) {
// Hide the existing sub-layer from the render stack so the
// staged preview shows correctly without doubling the effect.
@@ -303,8 +335,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span>
</div>
<label class="ge-adj-preset-field">Preset
<select class="ge-adj-preset-select">
<option value="custom"${editing ? ' selected' : ''}>Custom</option>
${presetOptions.map(name => `<option value="${name}"${!editing && name === 'Default' ? ' selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<div class="ge-adj-body" data-adj-body></div>
<div class="ge-adj-foot">
<button class="ge-btn ge-btn-sm ge-adj-reset-btn" data-adj-action="reset">Reset</button>
<span class="ge-adj-foot-spacer"></span>
<button class="ge-btn ge-btn-sm ge-adj-compare-btn" data-adj-action="compare" aria-pressed="false">Compare</button>
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
</div>
@@ -330,6 +371,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const body = pop.querySelector('[data-adj-body]');
buildAdjBody(layer, type, body, pop);
body.addEventListener('input', () => {
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'custom';
});
const popupRect = pop.getBoundingClientRect();
if (!window.matchMedia('(max-width: 820px)').matches) {
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
pop.style.top = `${clampedTop}px`;
}
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
@@ -391,14 +441,52 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
e.stopPropagation();
closeAdjPopup();
});
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._stagedAdj.params = defaultAdjParams(type);
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'Default';
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
e.preventDefault();
e.stopPropagation();
const preset = e.currentTarget.value;
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
layer._adjFinalKey = null;
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._adjCompare = !layer._adjCompare;
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
layer._adjFinalKey = null;
composite();
});
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
suppressLayerGhostTap();
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
if (!layer._newAdjustmentLayer) {
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
}
const params = layer._stagedAdj.params;
layer._stagedAdj = null;
if (editing) {
layer._adjPreview = false;
layer._adjCompare = false;
if (firstClass) {
layer.adjustment = { type, params };
layer.name = layer.name || adjLayerLabel(type);
layer._newAdjustmentLayer = false;
layer._newAdjustmentUndoLength = null;
} else if (editing) {
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
if (existing) existing.params = params;
layer._editingAdjId = null;
@@ -426,6 +514,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
state.adjRafPending = true;
requestAnimationFrame(() => {
state.adjRafPending = false;
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
layer._adjFinalKey = null;
composite();
});
@@ -433,6 +522,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
function buildAdjBody(layer, type, body, popEl) {
const p = layer._stagedAdj.params;
const levelValues = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
if (channel === 'rgb') return p;
if (!p.channels) p.channels = {};
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel];
};
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix) => `
<div class="ge-adj-row" data-adj-key="${key}">
@@ -449,14 +545,46 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
`;
} else if (type === 'exposure') {
body.innerHTML = `
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`;
} else if (type === 'white-balance') {
body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
`;
} else if (type === 'hue-saturation') {
const hSlider = Math.round(p.hue);
const sSlider = Math.round((p.saturation - 1) * 100);
body.innerHTML = `
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
`;
} else if (type === 'vibrance') {
body.innerHTML = `
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
<p class="ge-adj-help">Boosts muted colors more than already-saturated colors.</p>
`;
} else if (type === 'black-white') {
body.innerHTML = `
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
<p class="ge-adj-help">Mixes the source channels into a neutral grayscale result.</p>
`;
} else if (type === 'shadows-highlights') {
body.innerHTML = `
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
<p class="ge-adj-help">Recover detail in dark and bright tones without shifting midtones.</p>
`;
} else if (type === 'levels') {
const values = levelValues();
// Histogram canvas + sliders. Histogram is computed from the
// layer's pixel data (after any adjLayers below this one) so
// the user is matching levels against what they're really seeing.
@@ -464,6 +592,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
// vertical space; open by default on desktop.
const isMobile = window.matchMedia('(max-width: 820px)').matches;
body.innerHTML = `
<label class="ge-adj-channel-field">Channel
<select class="ge-adj-channel-select">
<option value="rgb"${(p.channel || 'rgb') === 'rgb' ? ' selected' : ''}>RGB</option>
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
<summary>Histogram</summary>
<div class="ge-adj-hist-wrap">
@@ -474,20 +610,60 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
</div>
</div>
<div class="ge-adj-clip-status" aria-live="polite"></div>
</details>
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
`;
const hist = body.querySelector('.ge-adj-histogram');
drawHistogram(hist, layer);
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const clipStatus = body.querySelector('.ge-adj-clip-status');
const renderHistogram = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const stats = drawHistogram(hist, histogramLayer, channel);
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
};
renderHistogram();
wireHistogramHandles(body, layer, type);
// Redraw histogram when the user opens the disclosure (canvas
// dimensions are layout-dependent).
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
if (e.target.open) drawHistogram(hist, layer);
if (e.target.open) renderHistogram();
});
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'curves') {
if (!p.points) p.points = defaultAdjParams('curves').points;
if (!p.channel) p.channel = 'rgb';
body.innerHTML = `
<label class="ge-adj-channel-field">Channel
<select class="ge-adj-channel-select">
<option value="rgb"${p.channel === 'rgb' ? ' selected' : ''}>RGB</option>
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
<div class="ge-curves-wrap">
<canvas class="ge-curves-canvas" width="280" height="180" aria-label="Editable tone curve"></canvas>
</div>
<p class="ge-adj-help">Click the curve to add a point. Drag to adjust. Double-click a point to remove it.</p>
`;
const canvas = body.querySelector('.ge-curves-canvas');
wireCurveEditor(canvas, layer);
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
@@ -527,6 +703,49 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'selective-color') {
if (!p.range) p.range = 'reds';
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
const values = p.ranges[p.range];
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
body.innerHTML = `
<label class="ge-adj-channel-field">Colors
<select class="ge-adj-selective-range">
${ranges.map(name => `<option value="${name}"${p.range === name ? ' selected' : ''}>${name[0].toUpperCase() + name.slice(1)}</option>`).join('')}
</select>
</label>
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
${sliderRow('black', 'Black', -100, 100, values.black, '')}
`;
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
p.range = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'gradient-map') {
body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row">
<label>Shadows <input type="color" data-gradient-key="shadows" value="${p.shadows}"></label>
<label>Highlights <input type="color" data-gradient-key="highlights" value="${p.highlights}"></label>
</div>
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
<label class="ge-adj-toggle-row"><span>Reverse</span><input type="checkbox" data-gradient-reverse${p.reverse ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
`;
body.querySelectorAll('[data-gradient-key]').forEach(input => {
input.addEventListener('input', () => {
p[input.dataset.gradientKey] = input.value;
const preview = body.querySelector('.ge-gradient-map-preview');
preview?.style.setProperty('--ge-gradient-start', p.shadows);
preview?.style.setProperty('--ge-gradient-end', p.highlights);
scheduleAdjRefresh(layer);
});
});
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
p.reverse = event.target.checked;
scheduleAdjRefresh(layer);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
@@ -550,13 +769,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
p[key] = defaults[key];
} else if (type === 'levels') {
p[key] = defaults[key];
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
values[key] = defaultValues[key];
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
} else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key];
}
layer._adjFinalKey = null;
composite();
@@ -573,21 +797,142 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
p[key] = 1 + raw / 100;
} else if (key === 'hue') {
p.hue = raw; display = raw + ' °';
} else if (key === 'lightness') {
p.lightness = raw;
}
} else if (type === 'exposure') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
p[key] = raw;
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
if (key === 'gamma') {
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
} else {
p[key] = raw;
values[key] = raw;
}
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = raw;
} else if (type === 'selective-color') {
p.ranges[p.range][key] = raw;
} else if (type === 'gradient-map') {
p.midpoint = raw;
display = raw + '%';
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
}
function wireCurveEditor(canvas, layer) {
if (!canvas) return;
const params = layer._stagedAdj.params;
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
const points = () => params.points[params.channel] || params.points.rgb;
const canvasPoint = event => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
};
};
const draw = () => {
const source = layer.kind === 'adjustment' && getAdjustmentSource
? getAdjustmentSource(layer)
: layer.canvas;
drawHistogram(canvas, { canvas: source });
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = 'rgba(255,255,255,.12)';
ctx.lineWidth = 1;
for (let index = 1; index < 4; index += 1) {
const x = Math.round(width * index / 4) + .5;
const y = Math.round(height * index / 4) + .5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
}
const list = points();
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath();
list.forEach((point, index) => {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
for (const point of list) {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
ctx.fillStyle = '#0b0d10';
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
};
const nearestIndex = event => {
const point = canvasPoint(event);
let nearest = -1;
let distance = 14;
points().forEach((candidate, index) => {
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
if (next < distance) { distance = next; nearest = index; }
});
return nearest;
};
canvas.addEventListener('pointerdown', event => {
event.preventDefault();
const list = points();
let index = nearestIndex(event);
if (index < 0) {
const point = canvasPoint(event);
list.push([Math.round(point.x), Math.round(point.y)]);
list.sort((a, b) => a[0] - b[0]);
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
}
canvas.setPointerCapture(event.pointerId);
const move = moveEvent => {
const next = canvasPoint(moveEvent);
const current = list[index];
const endpoint = index === 0 || index === list.length - 1;
current[0] = endpoint
? (index === 0 ? 0 : 255)
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
current[1] = Math.round(next.y);
draw();
scheduleAdjRefresh(layer);
};
const up = () => {
canvas.releasePointerCapture(event.pointerId);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', up);
};
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', up);
draw();
scheduleAdjRefresh(layer);
});
canvas.addEventListener('dblclick', event => {
const index = nearestIndex(event);
const list = points();
if (index <= 0 || index >= list.length - 1) return;
list.splice(index, 1);
draw();
scheduleAdjRefresh(layer);
});
draw();
}
// Position the three histogram triangle handles by current staged
// values + wire pointer drags.
function wireHistogramHandles(bodyEl, layer, type) {
@@ -595,9 +940,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const canvas = bodyEl.querySelector('.ge-adj-histogram');
if (!wrap || !canvas) return;
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
const levelParams = () => {
const root = layer._stagedAdj.params;
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
return channel === 'rgb' ? root : root.channels[channel];
};
const placeHandles = () => {
const w = canvas.getBoundingClientRect().width;
const p = layer._stagedAdj.params;
const p = levelParams();
const xB = (p.inBlack / 255) * w;
const xW = (p.inWhite / 255) * w;
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
@@ -623,7 +973,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const onMove = (ev) => {
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
const v = Math.round((x / rect.width) * 255);
const p = layer._stagedAdj.params;
const p = levelParams();
if (which === 'inBlack') {
p.inBlack = Math.min(p.inWhite - 1, v);
} else if (which === 'inWhite') {
@@ -642,14 +992,26 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
// Update visible slider rows + value labels.
const updateRow = (key, displayVal) => {
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
const values = levelParams();
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
const val = sl?.parentElement.querySelector('.ge-adj-value');
if (val) val.textContent = displayVal;
};
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
drawHistogram(canvas, layer);
const values = levelParams();
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
? layer._stagedAdj.params.channel : 'rgb';
const stats = drawHistogram(canvas, histogramLayer, channel);
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
scheduleAdjRefresh(layer);
};
const onUp = () => {
+22 -5
View File
@@ -12,9 +12,11 @@
* canvas: HTMLCanvasElement,
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
* }} layer Source layer.
* @param {'rgb'|'red'|'green'|'blue'} channel Channel to inspect. `rgb` uses luminance.
* @returns {{black: number, white: number, samples: number}}
*/
export function drawHistogram(canvas, layer) {
if (!canvas) return;
export function drawHistogram(canvas, layer, channel = 'rgb') {
if (!canvas) return { black: 0, white: 0, samples: 0 };
const w = canvas.width, h = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
@@ -33,11 +35,21 @@ export function drawHistogram(canvas, layer) {
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
const hist = new Uint32Array(256);
let samples = 0;
let black = 0;
let white = 0;
for (let i = 0; i < img.length; i += 4) {
if (img[i + 3] < 8) continue; // skip near-transparent
// Rec. 709 luminance — common choice for histograms in photo editors.
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
hist[Math.min(255, Y)]++;
const value = channel === 'red' ? img[i]
: channel === 'green' ? img[i + 1]
: channel === 'blue' ? img[i + 2]
// Rec. 709 luminance — common choice for composite histograms.
: 0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2];
const bucket = Math.max(0, Math.min(255, value | 0));
hist[bucket]++;
samples++;
if (bucket === 0) black++;
if (bucket === 255) white++;
}
let peak = 1;
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
@@ -64,4 +76,9 @@ export function drawHistogram(canvas, layer) {
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
}
return {
black: samples ? black / samples * 100 : 0,
white: samples ? white / samples * 100 : 0,
samples,
};
}
+263 -29
View File
@@ -1,5 +1,5 @@
/**
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
* Apply a Brightness/Contrast, Black & White, Hue/Saturation, Levels, Curves, or Color Balance
* adjustment to a source canvas and return a fresh canvas with the
* result. Pure pixel math — no DOM, no module state.
*
@@ -11,8 +11,111 @@
* { type: 'brightness-contrast', params: { brightness, contrast } }
* { type: 'hue-saturation', params: { hue, saturation } }
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
* { type: 'color-balance', params: { shadows, midtones, highlights } }
*/
function clampByte(value) {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
}
function finiteOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function levelLut(values = {}) {
const inLow = Math.max(0, Math.min(254, finiteOr(values.inBlack, 0)));
const inHigh = Math.max(inLow + 1, Math.min(255, finiteOr(values.inWhite, 255)));
const gamma = Math.max(0.1, finiteOr(values.gamma, 1));
const outLow = Math.max(0, Math.min(255, finiteOr(values.outBlack, 0)));
const outHigh = Math.max(outLow, Math.min(255, finiteOr(values.outWhite, 255)));
const lut = new Uint8ClampedArray(256);
for (let value = 0; value < 256; value += 1) {
const normalized = Math.max(0, Math.min(1, (value - inLow) / (inHigh - inLow)));
lut[value] = clampByte(Math.pow(normalized, 1 / gamma) * (outHigh - outLow) + outLow);
}
return lut;
}
function normalizedCurvePoints(points) {
const clean = (Array.isArray(points) ? points : [])
.filter(point => Array.isArray(point) && point.length >= 2)
.map(point => [clampByte(Number(point[0]) || 0), clampByte(Number(point[1]) || 0)])
.sort((a, b) => a[0] - b[0]);
if (!clean.length || clean[0][0] !== 0) clean.unshift([0, clean[0]?.[1] ?? 0]);
if (clean[clean.length - 1][0] !== 255) clean.push([255, clean[clean.length - 1]?.[1] ?? 255]);
const unique = [];
for (const point of clean) {
if (unique.length && unique[unique.length - 1][0] === point[0]) unique[unique.length - 1] = point;
else unique.push(point);
}
return unique;
}
export function curveLut(points) {
const clean = normalizedCurvePoints(points);
const lut = new Uint8ClampedArray(256);
let segment = 0;
for (let value = 0; value < 256; value += 1) {
while (segment < clean.length - 2 && value > clean[segment + 1][0]) segment += 1;
const left = clean[segment];
const right = clean[Math.min(segment + 1, clean.length - 1)];
const span = Math.max(1, right[0] - left[0]);
const amount = Math.max(0, Math.min(1, (value - left[0]) / span));
lut[value] = clampByte(left[1] + (right[1] - left[1]) * amount);
}
return lut;
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const lightness = (max + min) / 2;
if (max === min) return [0, 0, lightness];
const delta = max - min;
const saturation = lightness > .5 ? delta / (2 - max - min) : delta / (max + min);
let hue;
if (max === r) hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
else if (max === g) hue = ((b - r) / delta + 2) / 6;
else hue = ((r - g) / delta + 4) / 6;
return [hue, saturation, lightness];
}
function hueChannel(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function hslToRgb(h, s, l) {
if (!s) return [l * 255, l * 255, l * 255];
const q = l < .5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [hueChannel(p, q, h + 1 / 3) * 255, hueChannel(p, q, h) * 255, hueChannel(p, q, h - 1 / 3) * 255];
}
function parseHexColor(value, fallback) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(value || ''));
const hex = match ? match[1] : fallback;
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
}
function hueDistance(a, b) {
const distance = Math.abs(a - b);
return Math.min(distance, 1 - distance);
}
function selectiveRangeWeight(name, hue, saturation, lightness) {
if (name === 'blacks') return Math.max(0, Math.min(1, (0.55 - lightness) / 0.45));
if (name === 'neutrals') return Math.max(0, Math.min(1, (1 - saturation) * (1 - Math.abs(lightness - .5) * 1.5)));
const centers = { reds: 0, yellows: 1 / 6, greens: 1 / 3, cyans: .5, blues: 2 / 3, magentas: 5 / 6 };
const distance = hueDistance(hue, centers[name] ?? 0);
return Math.max(0, 1 - distance * 6) * saturation;
}
export function applyAdjustment(srcCanvas, adj) {
const w = srcCanvas.width, h = srcCanvas.height;
const out = document.createElement('canvas');
@@ -27,37 +130,129 @@ export function applyAdjustment(srcCanvas, adj) {
octx.filter = 'none';
return out;
}
if (adj.type === 'hue-saturation') {
const p = adj.params;
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
octx.drawImage(srcCanvas, 0, 0);
octx.filter = 'none';
return out;
}
// Levels + Color Balance need per-pixel math.
// The remaining adjustments need deterministic per-pixel math so preview,
// flattening, and reopened documents produce the same result.
octx.drawImage(srcCanvas, 0, 0);
const img = octx.getImageData(0, 0, w, h);
const d = img.data;
if (adj.type === 'exposure') {
const p = adj.params || {};
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
const offset = finiteOr(p.offset, 0);
const gamma = Math.max(.1, finiteOr(p.gamma, 1));
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i] / 255 * multiplier + offset)), 1 / gamma) * 255);
d[i + 1] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 1] / 255 * multiplier + offset)), 1 / gamma) * 255);
d[i + 2] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 2] / 255 * multiplier + offset)), 1 / gamma) * 255);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'white-balance') {
const p = adj.params || {};
const temperature = finiteOr(p.temperature, 0) / 100;
const tint = finiteOr(p.tint, 0) / 100;
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(d[i] * (1 + temperature * .28 + tint * .09));
d[i + 1] = clampByte(d[i + 1] * (1 - tint * .18));
d[i + 2] = clampByte(d[i + 2] * (1 - temperature * .28 + tint * .09));
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'hue-saturation') {
const p = adj.params || {};
const hueShift = finiteOr(p.hue, 0) / 360;
const saturationScale = Math.max(0, finiteOr(p.saturation, 1));
const lightnessShift = finiteOr(p.lightness, 0) / 100;
for (let i = 0; i < d.length; i += 4) {
let [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
hue = (hue + hueShift + 1) % 1;
saturation = Math.max(0, Math.min(1, saturation * saturationScale));
lightness = Math.max(0, Math.min(1, lightness + lightnessShift));
const rgb = hslToRgb(hue, saturation, lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'vibrance') {
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
for (let i = 0; i < d.length; i += 4) {
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
const adjustedSaturation = amount >= 0
? saturation + (1 - saturation) * amount
: saturation * (1 + amount);
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'black-white') {
const p = adj.params || {};
const red = finiteOr(p.red, 30) / 100;
const green = finiteOr(p.green, 59) / 100;
const blue = finiteOr(p.blue, 11) / 100;
const constant = finiteOr(p.constant, 0) * 2.55;
for (let i = 0; i < d.length; i += 4) {
const gray = d[i] * red + d[i + 1] * green + d[i + 2] * blue + constant;
d[i] = clampByte(gray);
d[i + 1] = clampByte(gray);
d[i + 2] = clampByte(gray);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'shadows-highlights') {
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
const toneAdjust = (value, amount, weight) => amount >= 0
? value + (255 - value) * amount * weight
: value + value * amount * weight;
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const shadowWeight = (1 - luminance) ** 2;
const highlightWeight = luminance ** 2;
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'levels') {
const l = adj.params;
const inLow = Math.max(0, Math.min(254, l.inBlack));
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
const gamma = Math.max(0.1, l.gamma || 1);
const outLow = Math.max(0, Math.min(255, l.outBlack));
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
const inv = 1.0 / gamma;
const span = (outHigh - outLow);
const lut = new Uint8ClampedArray(256);
for (let v = 0; v < 256; v++) {
let t = (v - inLow) / (inHigh - inLow);
if (t < 0) t = 0; else if (t > 1) t = 1;
t = Math.pow(t, inv);
lut[v] = Math.round(t * span + outLow);
}
const master = levelLut(l);
const red = levelLut(l.channels?.red);
const green = levelLut(l.channels?.green);
const blue = levelLut(l.channels?.blue);
for (let i = 0; i < d.length; i += 4) {
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
d[i] = red[master[d[i]]];
d[i + 1] = green[master[d[i + 1]]];
d[i + 2] = blue[master[d[i + 2]]];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'curves') {
const points = adj.params?.points || {};
const master = curveLut(points.rgb);
const red = curveLut(points.red);
const green = curveLut(points.green);
const blue = curveLut(points.blue);
for (let i = 0; i < d.length; i += 4) {
d[i] = red[master[d[i]]];
d[i + 1] = green[master[d[i + 1]]];
d[i + 2] = blue[master[d[i + 2]]];
}
octx.putImageData(img, 0, 0);
return out;
@@ -95,6 +290,43 @@ export function applyAdjustment(srcCanvas, adj) {
return out;
}
if (adj.type === 'selective-color') {
const ranges = adj.params?.ranges || {};
for (let i = 0; i < d.length; i += 4) {
let r = d[i], g = d[i + 1], b = d[i + 2];
const [hue, saturation, lightness] = rgbToHsl(r, g, b);
for (const [name, values] of Object.entries(ranges)) {
const weight = selectiveRangeWeight(name, hue, saturation, lightness) * .65;
if (weight <= 0) continue;
const black = finiteOr(values.black, 0) / 100;
r += (-finiteOr(values.cyan, 0) / 100 * 255 - black * r) * weight;
g += (-finiteOr(values.magenta, 0) / 100 * 255 - black * g) * weight;
b += (-finiteOr(values.yellow, 0) / 100 * 255 - black * b) * weight;
}
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'gradient-map') {
const p = adj.params || {};
let shadows = parseHexColor(p.shadows, '000000');
let highlights = parseHexColor(p.highlights, 'ffffff');
if (p.reverse) [shadows, highlights] = [highlights, shadows];
const midpoint = Math.max(.01, Math.min(.99, finiteOr(p.midpoint, 50) / 100));
const exponent = Math.log(.5) / Math.log(midpoint);
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const amount = Math.pow(luminance, exponent);
d[i] = clampByte(shadows[0] + (highlights[0] - shadows[0]) * amount);
d[i + 1] = clampByte(shadows[1] + (highlights[1] - shadows[1]) * amount);
d[i + 2] = clampByte(shadows[2] + (highlights[2] - shadows[2]) * amount);
}
octx.putImageData(img, 0, 0);
return out;
}
return out;
}
@@ -215,14 +447,16 @@ export function renderLayerWithAdjLayers(layer) {
const editingId = layer._editingAdjId || null;
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
const staged = layer._stagedAdj;
if (stack.length === 0 && !staged) {
if (stack.length === 0 && (!staged || layer._adjCompare)) {
layer._adjFinalKey = '';
return layer.canvas;
}
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
(editingId ? `|E:${editingId}` : '');
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
const compare = !!layer._adjCompare;
const fullSig = `${sig}|C:${compare ? 1 : 0}`;
if (layer._adjFinal && layer._adjFinalKey === fullSig) return layer._adjFinal;
let cur = layer.canvas;
const w = layer.canvas.width, h = layer.canvas.height;
for (const adj of stack) {
@@ -240,10 +474,10 @@ export function renderLayerWithAdjLayers(layer) {
cur = blend;
}
}
if (staged) {
if (staged && !compare) {
cur = applyAdjustment(cur, staged);
}
layer._adjFinal = cur;
layer._adjFinalKey = sig;
layer._adjFinalKey = fullSig;
return cur;
}
+63
View File
@@ -0,0 +1,63 @@
/** Shared normalization for editable linear-gradient stops. */
export const MAX_GRADIENT_STOPS = 12;
function finite(value, fallback, min, max) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
function color(value, fallback) {
const text = String(value || '').trim();
return text || fallback;
}
export function legacyGradientStops({
start = '#ffffff',
mid = '#808080',
midEnabled = false,
midPosition = 50,
end = '#000000',
} = {}) {
const stops = [{ position: 0, color: color(start, '#ffffff') }];
if (midEnabled) {
stops.push({
position: finite(midPosition, 50, 1, 99),
color: color(mid, '#808080'),
});
}
stops.push({ position: 100, color: color(end, '#000000') });
return stops;
}
export function normalizeGradientStops(value, legacy = {}) {
const source = Array.isArray(value) && value.length ? value : legacyGradientStops(legacy);
const stops = source.map((stop, index) => ({
position: finite(stop?.position, index === 0 ? 0 : 100, 0, 100),
color: color(stop?.color, index === 0 ? '#ffffff' : '#000000'),
}));
stops.sort((a, b) => a.position - b.position);
const unique = [];
for (const stop of stops) {
const previous = unique.at(-1);
if (previous && Math.abs(previous.position - stop.position) < 0.0001) {
unique[unique.length - 1] = stop;
} else {
unique.push(stop);
}
}
if (!unique.length || unique[0].position > 0) {
unique.unshift({ position: 0, color: unique[0]?.color || '#ffffff' });
} else {
unique[0].position = 0;
}
if (unique.at(-1).position < 100) {
unique.push({ position: 100, color: unique.at(-1)?.color || '#000000' });
} else {
unique.at(-1).position = 100;
}
const first = unique[0];
const last = unique.at(-1);
return [first, ...unique.slice(1, -1).slice(0, MAX_GRADIENT_STOPS - 2), last];
}
+87
View File
@@ -0,0 +1,87 @@
/** Adaptive bounds for raw ImageData history snapshots. */
export const MAX_HISTORY_ENTRIES = 30;
export const MAX_HISTORY_BYTES = 192 * 1024 * 1024;
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;
let bytes = imageDataBytes(snapshot.wand?.imageData);
bytes += imageDataBytes(snapshot.lastSelection?.imageData);
for (const selection of snapshot.savedSelections || []) bytes += imageDataBytes(selection.imageData);
for (const group of snapshot.layerGroups || []) {
for (const mask of group.masks || []) bytes += imageDataBytes(mask.imageData);
}
for (const layer of snapshot.layers || []) {
bytes += imageDataBytes(layer.imageData);
bytes += imageDataBytes(layer.placed?.sourceImageData);
for (const mask of layer.masks || []) bytes += imageDataBytes(mask.imageData);
}
return bytes;
}
export function trimHistoryStack(
stack,
maxEntries = MAX_HISTORY_ENTRIES,
maxBytes = MAX_HISTORY_BYTES,
) {
while (stack.length > maxEntries) stack.shift();
let bytes = historyByteSize(stack);
// Keep the newest state even when one snapshot alone exceeds the budget.
while (stack.length > 1 && bytes > maxBytes) {
stack.shift();
bytes = historyByteSize(stack);
}
return bytes;
}
+182 -151
View File
@@ -8,19 +8,21 @@
* Enter confirm in-progress transform
* Esc cancel transform / lasso / crop (in priority order)
* Ctrl+Z undo (Shift adds redo)
* Ctrl+Shift+D deselect (clears wand + lasso)
* Ctrl+D deselect (clears wand + lasso)
* 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+Alt+A select all canvas (lasso polygon = full bounds)
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
* + internal clipboard)
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
* Ctrl+Alt+G create/release clipping mask
* Ctrl+A select all canvas
* Ctrl+C/X copy / cut the selected surface (image clipboard
* + internal clipboard, preserving its document offset)
* Ctrl+V (handled by the paste event listener)
* Tool keys (V, B, E, L, …) → toolbar click
* Hold Space temporarily pan without changing the active tool
* [ / ] shrink / grow brush size proportionally
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
* Delete / Backspace (wand or lasso) → delete pixels
*
* @param {{
@@ -33,6 +35,7 @@
* toggleShortcuts: (show?: boolean) => void,
* confirmTransform: () => void,
* cancelTransform: () => void,
* nudgeTransform: (dx: number, dy: number) => boolean,
* startTransform: () => void,
* resizeCustomPrompt: () => void,
* addEmptyLayer: () => void,
@@ -42,31 +45,99 @@
* wandCopyToNewLayer: () => void,
* lassoDeleteSelection: () => void,
* lassoCopyToLayer: () => void,
* lassoToMask: () => void,
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
* drawLassoOverlay: () => void,
* copyPixelsToClipboard: (options: {cut: boolean}) => Promise<void>,
* activeLayer: () => object | null,
* deleteSelectedLayers: () => boolean | Promise<boolean>,
* duplicateActiveLayer: () => boolean,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
import { createMarqueeMask } from './selection-mask.js';
let keyboardBindings;
export function wireKeyboardShortcuts(deps) {
keyboardBindings?.abort();
keyboardBindings = new AbortController();
const { signal } = keyboardBindings;
const {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer,
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
buildLassoMask, drawLassoOverlay,
activeLayer, uiModule,
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
lassoDeleteSelection, lassoCopyToLayer,
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
setTemporaryPan,
nudgeActiveLayer, endLayerNudge,
toggleQuickMask, nudgeSelection,
deselectSelection, fillSelection,
} = deps;
const isTypingTarget = (target) => target && (
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' || target.isContentEditable
);
const releaseTemporaryPan = () => setTemporaryPan?.(false);
document.addEventListener('keyup', (e) => {
if (e.code === 'Space') releaseTemporaryPan();
if (e.key.startsWith('Arrow')) endLayerNudge?.();
}, { signal });
window.addEventListener('blur', releaseTemporaryPan, { signal });
document.addEventListener('keydown', (e) => {
if (!state.editorOpen) return;
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
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);
return;
}
if (!isTypingTarget(e.target) && state.tool === 'marquee' && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeSelection?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && state.transformActive && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeTransform?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && ['move', 'transform'].includes(state.tool) && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeActiveLayer?.(...delta)) {
e.preventDefault();
return;
}
}
// `?` toggles the cheatsheet. Don't fire while typing in a text
// field — the user might be typing a prompt with a `?`.
if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
@@ -85,33 +156,27 @@ export function wireKeyboardShortcuts(deps) {
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
// AZERTY / QWERTZ.
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
// Ctrl+Shift+D = Deselect: clears the wand selection (and
// lasso if active) without affecting layers.
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
if (state.wandMask || state.lassoPoints.length) {
e.preventDefault();
if (state.wandMask) {
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandLastSeed = null;
}
if (state.lassoPoints.length) {
state.lassoPoints = [];
state.lassoActive = false;
}
composite();
}
if (e.key.toLowerCase() === 'z') {
e.preventDefault(); e.stopPropagation();
if (e.shiftKey) redo(); else undo();
return;
}
// Preserve the old chord as an alias while supporting the familiar one.
if (!e.altKey && e.key.toLowerCase() === 'd') {
e.preventDefault(); e.stopPropagation();
deselectSelection?.();
return;
}
// Save shortcuts — match the hints shown in the Save dropdown.
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
e.preventDefault();
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
e.stopPropagation();
return;
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
// Ctrl+Alt+I — invert current selection. Uses e.code so
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+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') {
@@ -119,126 +184,60 @@ export function wireKeyboardShortcuts(deps) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
addEmptyLayer();
return;
}
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
// clipboard + new layer + erase. Ctrl+C = copy.
// (Legacy `&& !_wandActive` clause referenced an undeclared
// variable — removed; the wand is selection-only and has no
// "active drag" state.)
if (state.wandMask) {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
wandDeleteSelection();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
e.preventDefault();
const isCut = e.key === 'x';
const src = state.layers.find(l => l.id === state.wandLayerId);
if (!src) return;
// Clip source by wand mask into a temp canvas.
const w = src.canvas.width, h = src.canvas.height;
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
const tCtx = tmp.getContext('2d');
tCtx.drawImage(src.canvas, 0, 0);
tCtx.globalCompositeOperation = 'destination-in';
tCtx.drawImage(state.wandMask, 0, 0);
state.internalClipboard = tmp;
tmp.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
}
}, 'image/png');
if (isCut) {
// Cut also moves the selection to a new layer + erases source.
wandCopyToNewLayer();
wandDeleteSelection();
}
return;
}
}
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects.
if (!e.altKey && e.code === 'KeyJ') {
e.preventDefault();
const layer = activeLayer();
if (!layer) return;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
const w = layer.canvas.width, h = layer.canvas.height;
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
const srcData = layer.ctx.getImageData(0, 0, w, h);
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
// Build clipped image.
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
const tCtx = tmp.getContext('2d');
const outData = tCtx.createImageData(w, h);
for (let i = 0; i < w * h; i++) {
const mv = maskData.data[i * 4] / 255;
if (mv > 0) {
outData.data[i*4] = srcData.data[i*4];
outData.data[i*4+1] = srcData.data[i*4+1];
outData.data[i*4+2] = srcData.data[i*4+2];
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
}
}
tCtx.putImageData(outData, 0, 0);
state.internalClipboard = tmp;
const isCut = e.key === 'x';
tmp.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
}
}, 'image/png');
if (e.key === 'x') {
const savedPts = [...state.lassoPoints];
state.lassoPoints = savedPts;
lassoDeleteSelection();
} else {
state.lassoPoints = [];
composite();
}
e.stopPropagation();
if (state.wandMask) wandCopyToNewLayer();
else if (state.lassoPoints.length >= 3) lassoCopyToLayer();
else duplicateActiveLayer?.();
return;
}
// Ctrl+C with no active selection → copy the entire active layer
// to the system clipboard as a PNG. Gives a "just copy this image"
// shortcut without having to lasso-select-all first. The
// selection-aware Ctrl+C paths above run first (wand + lasso),
// so this only fires when neither is active.
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
const layer = activeLayer();
if (layer && layer.canvas && layer.canvas.width > 0) {
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
if (e.altKey && e.code === 'KeyG') {
const row = [...document.querySelectorAll('.ge-layer-item[data-layer-id]')]
.find(item => item.dataset.layerId === state.activeLayerId);
const button = row?.querySelector('.ge-layer-clip-btn');
if (button && !button.disabled) {
e.preventDefault();
layer.canvas.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
.then(() => uiModule.showToast('Layer copied to clipboard'))
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
}
}, 'image/png');
return;
e.stopPropagation();
button.click();
}
return;
}
// Ctrl+Alt+A = select all canvas.
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
if (!e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x')) {
e.preventDefault();
state.lassoPoints = [
{ x: 0, y: 0 }, { x: state.imgWidth, y: 0 },
{ x: state.imgWidth, y: state.imgHeight }, { x: 0, y: state.imgHeight },
];
state.lassoActive = false;
e.stopPropagation();
void copyPixelsToClipboard({ cut: e.key.toLowerCase() === 'x' });
return;
}
// Keep Ctrl+Alt+A as an alias for existing users.
if (e.key.toLowerCase() === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
e.preventDefault();
e.stopPropagation();
saveState('Select all');
state.wandMask = createMarqueeMask(
state.imgWidth,
state.imgHeight,
{ x: 0, y: 0, w: state.imgWidth, h: state.imgHeight },
'rectangle',
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'marquee';
state.wandLastSeed = null;
state.lassoPoints = [];
composite();
drawLassoOverlay();
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
}
// Ctrl+V handled by the paste event listener.
@@ -246,11 +245,50 @@ export function wireKeyboardShortcuts(deps) {
return;
}
// Tool shortcuts (only when not typing in an input).
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (isTypingTarget(e.target)) return;
// Delete pixels for a selection, otherwise delete the selected layer(s).
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.wandMask) {
e.preventDefault();
wandDeleteSelection();
return;
}
if (state.lassoPoints.length >= 3) {
e.preventDefault();
lassoDeleteSelection();
return;
}
const layer = activeLayer?.();
const activeMask = layer?.activeMaskId &&
layer.masks?.some(mask => mask.id === layer.activeMaskId);
const group = state.activeGroupId &&
state.layerGroups?.find(item => item.id === state.activeGroupId);
const activeGroupMask = group?.activeMaskId &&
group.masks?.some(mask => mask.id === group.activeMaskId);
if (state.transformActive || state.cropping || state.cropMoving ||
state.marqueeActive || state.selectionMoving || state.lassoActive ||
activeMask || activeGroupMask || state.maskInspectMode) return;
if (deleteSelectedLayers) {
e.preventDefault();
deleteSelectedLayers();
return;
}
}
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'q') {
e.preventDefault();
toggleQuickMask?.();
return;
}
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
e.preventDefault();
e.stopPropagation();
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
if (toolBtn) toolBtn.click();
return;
}
// Bracket keys for brush size — ±10% multiplier mirrors the
// exponential slider curve so each press feels the same at any
@@ -260,12 +298,5 @@ export function wireKeyboardShortcuts(deps) {
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
try { brushSizeSync(null); } catch {}
}
// Lasso shortcuts (when selection exists).
if (state.lassoPoints.length >= 3) {
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
}
});
}, { signal });
}
+144
View File
@@ -0,0 +1,144 @@
/** Alpha clipping for flat layer stacks and isolated groups. */
function scopeByLayer(editorState) {
const scopes = new Map();
for (const group of editorState.layerGroups || []) {
for (const id of group.layerIds || []) scopes.set(id, group.id);
}
return scopes;
}
export function clippingBaseForLayer(editorState, layerId) {
const layers = editorState.layers || [];
const index = layers.findIndex(layer => layer.id === layerId);
if (index < 0 || !layers[index]?.clipped || index === 0) return null;
const scopes = scopeByLayer(editorState);
const scope = scopes.get(layerId) || null;
if ((scopes.get(layers[index - 1].id) || null) !== scope) return null;
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const candidate = layers[cursor];
if ((scopes.get(candidate.id) || null) !== scope) return null;
if (candidate.kind === 'adjustment') return null;
if (!candidate.clipped) return candidate;
}
return null;
}
export function canToggleLayerClipping(editorState, layerId) {
const layer = (editorState.layers || []).find(item => item.id === layerId);
if (!layer) return false;
if (layer.clipped) return true;
layer.clipped = true;
const canClip = !!clippingBaseForLayer(editorState, layerId);
layer.clipped = false;
return canClip;
}
/** Clear clipping flags made invalid by delete, grouping, or reorder. */
export function normalizeLayerClipping(editorState) {
const cleared = [];
for (const layer of editorState.layers || []) {
layer.clipped = !!layer.clipped;
if (layer.clipped && !clippingBaseForLayer(editorState, layer.id)) {
layer.clipped = false;
cleared.push(layer.id);
}
}
return cleared;
}
function ensureScratchCanvas(editorState) {
const canvas = editorState.clippingCompositeCanvas || document.createElement('canvas');
editorState.clippingCompositeCanvas = canvas;
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
return canvas;
}
/** Draw one contiguous stack scope, bottom to top. */
export function drawLayerStack(target, editorState, layers, renderLayer, renderAdjustment = null) {
let base = null;
const drawNormal = layer => {
if (!layer.visible) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(renderLayer(layer), offset.x, offset.y);
};
for (const layer of layers) {
if (layer.kind === 'adjustment' && renderAdjustment) {
renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(layer), layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}
/** Async counterpart used by the worker-backed live effect compositor. */
export async function drawLayerStackAsync(target, editorState, layers, renderLayer, renderAdjustment = null, shouldContinue = () => true) {
let base = null;
const drawNormal = async layer => {
if (!layer.visible || !shouldContinue()) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
const rendered = await renderLayer(layer);
if (!shouldContinue() || !rendered) return;
target.drawImage(rendered, offset.x, offset.y);
};
for (const layer of layers) {
if (!shouldContinue()) return;
if (layer.kind === 'adjustment' && renderAdjustment) {
await renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
await drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const renderedLayer = await renderLayer(layer);
const renderedBase = await renderLayer(base);
if (!shouldContinue() || !renderedLayer || !renderedBase) return;
scratchCtx.drawImage(renderedLayer, layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}
+249
View File
@@ -0,0 +1,249 @@
/** Exact active-layer geometry used by controls, dragging, and key nudging. */
import { state } from './state.js';
import { selectedLayers, selectionLabel } from './layer-selection.js';
import { isLayerPositionLocked } from './layer-groups.js';
import { translatePlacedData } from './placed-layer.js';
const MAX_COORDINATE = 1000000;
export function normalizeLayerCoordinate(value, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return Math.round(Number(fallback) || 0);
return Math.max(-MAX_COORDINATE, Math.min(MAX_COORDINATE, Math.round(parsed)));
}
export function readLayerGeometry(editorState, layer) {
if (!layer?.canvas) return null;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return {
x: normalizeLayerCoordinate(offset.x),
y: normalizeLayerCoordinate(offset.y),
width: Math.max(1, Math.round(layer.canvas.width || 1)),
height: Math.max(1, Math.round(layer.canvas.height || 1)),
};
}
export function setLayerPosition(editorState, layer, x, y) {
if (!layer) return null;
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = {
x: normalizeLayerCoordinate(x, current.x),
y: normalizeLayerCoordinate(y, current.y),
};
editorState.layerOffsets.set(layer.id, next);
return next;
}
export function nudgeLayerPosition(editorState, layer, dx, dy) {
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return setLayerPosition(
editorState,
layer,
current.x + normalizeLayerCoordinate(dx),
current.y + normalizeLayerCoordinate(dy),
);
}
function moveLayerBy(editorState, layer, dx, dy) {
if (!layer || isLayerPositionLocked(editorState, layer)) return false;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = setLayerPosition(editorState, layer, offset.x + dx, offset.y + dy);
if (layer.kind === 'placed' && layer.placed) {
layer.placed = translatePlacedData(layer.placed, next.x - offset.x, next.y - offset.y);
}
for (const mask of layer.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - (next.x - offset.x),
y: (Number(mask.offset?.y) || 0) - (next.y - offset.y),
};
}
return next.x !== offset.x || next.y !== offset.y;
}
/** Move selected layers to a document edge/center without rasterizing them. */
export function alignSelectedLayers(editorState, layers, alignment) {
const targets = (layers || []).filter(layer => readLayerGeometry(editorState, layer));
if (targets.length < 2) return false;
let changed = false;
for (const layer of targets) {
const geometry = readLayerGeometry(editorState, layer);
let x = geometry.x;
let y = geometry.y;
if (alignment === 'left') x = 0;
else if (alignment === 'center') x = (editorState.imgWidth - geometry.width) / 2;
else if (alignment === 'right') x = editorState.imgWidth - geometry.width;
else if (alignment === 'top') y = 0;
else if (alignment === 'middle') y = (editorState.imgHeight - geometry.height) / 2;
else if (alignment === 'bottom') y = editorState.imgHeight - geometry.height;
changed = moveLayerBy(editorState, layer, x - geometry.x, y - geometry.y) || changed;
}
return changed;
}
/** Evenly distribute selected layer centers along one document axis. */
export function distributeSelectedLayers(editorState, layers, axis) {
const entries = (layers || []).map(layer => ({ layer, geometry: readLayerGeometry(editorState, layer) }))
.filter(entry => entry.geometry)
.sort((a, b) => a.geometry[axis === 'vertical' ? 'y' : 'x'] - b.geometry[axis === 'vertical' ? 'y' : 'x']);
if (entries.length < 3) return false;
const coordinate = axis === 'vertical' ? 'y' : 'x';
const size = axis === 'vertical' ? 'height' : 'width';
const first = entries[0].geometry[coordinate] + entries[0].geometry[size] / 2;
const lastEntry = entries[entries.length - 1];
const last = lastEntry.geometry[coordinate] + lastEntry.geometry[size] / 2;
const step = (last - first) / (entries.length - 1);
let changed = false;
entries.forEach((entry, index) => {
const target = first + step * index - entry.geometry[size] / 2;
const delta = target - entry.geometry[coordinate];
changed = moveLayerBy(editorState, entry.layer, axis === 'vertical' ? 0 : delta, axis === 'vertical' ? delta : 0) || changed;
});
return changed;
}
export function createLayerGeometryController({ activeLayer, saveState, composite }) {
let root = null;
let fieldHistorySaved = false;
let nudgeHistorySaved = false;
function shiftTransformFrame(dx, dy) {
if (!state.transformActive) return;
if (state.transformCenter) {
state.transformCenter = { x: state.transformCenter.x + dx, y: state.transformCenter.y + dy };
}
if (state.transformBounds) {
state.transformBounds = { ...state.transformBounds, x: state.transformBounds.x + dx, y: state.transformBounds.y + dy };
}
if (state.transformOrigOffset) {
state.transformOrigOffset.x += dx;
state.transformOrigOffset.y += dy;
}
}
const inputs = () => ({
x: root?.querySelector('#ge-layer-x') || null,
y: root?.querySelector('#ge-layer-y') || null,
width: root?.querySelector('#ge-layer-width') || null,
height: root?.querySelector('#ge-layer-height') || null,
name: root?.querySelector('#ge-layer-geometry-name') || null,
});
function sync() {
const layer = activeLayer();
const fields = inputs();
const geometry = readLayerGeometry(state, layer);
if (fields.name) fields.name.textContent = layer?.name || 'No layer selected';
for (const field of [fields.x, fields.y]) field && (field.disabled = !layer || isLayerPositionLocked(state, layer));
for (const field of [fields.width, fields.height]) field && (field.disabled = !layer);
if (!geometry) {
for (const field of [fields.x, fields.y, fields.width, fields.height]) {
if (field) field.value = '';
}
return null;
}
if (fields.x && document.activeElement !== fields.x) fields.x.value = String(geometry.x);
if (fields.y && document.activeElement !== fields.y) fields.y.value = String(geometry.y);
if (fields.width) fields.width.value = String(geometry.width);
if (fields.height) fields.height.value = String(geometry.height);
return geometry;
}
function moveTo(x, y, label = null) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const nextX = normalizeLayerCoordinate(x, before.x);
const nextY = normalizeLayerCoordinate(y, before.y);
if (before.x === nextX && before.y === nextY) {
sync();
return false;
}
if (label) saveState(label);
const dx = nextX - before.x;
const dy = nextY - before.y;
for (const selected of selectedLayers(state).filter(item => !isLayerPositionLocked(state, item))) {
const selectedOffset = state.layerOffsets.get(selected.id) || { x: 0, y: 0 };
setLayerPosition(state, selected, selectedOffset.x + dx, selectedOffset.y + dy);
if (selected.kind === 'placed' && selected.placed) {
selected.placed = translatePlacedData(selected.placed, dx, dy);
}
for (const mask of selected.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - dx,
y: (Number(mask.offset?.y) || 0) - dy,
};
}
}
if (state.transformActive && state.transformLayer?.id === layer.id) shiftTransformFrame(dx, dy);
composite();
sync();
return true;
}
function nudge(dx, dy) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const activeMask = (layer.masks || []).find(mask =>
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
if (activeMask) {
if (!nudgeHistorySaved) saveState(`Nudge mask "${activeMask.name || 'Layer Mask'}"`);
activeMask.offset = {
x: (Number(activeMask.offset?.x) || 0) + dx,
y: (Number(activeMask.offset?.y) || 0) + dy,
};
nudgeHistorySaved = true;
composite();
return true;
}
const current = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const label = !state.transformActive && !nudgeHistorySaved
? `Nudge ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`
: null;
const moved = moveTo(current.x + dx, current.y + dy, label);
if (moved) nudgeHistorySaved = true;
return moved;
}
function endNudge() {
nudgeHistorySaved = false;
}
function trackExternalMove(layer, before, next) {
if (state.transformActive && state.transformLayer?.id === layer?.id) {
shiftTransformFrame(next.x - before.x, next.y - before.y);
}
sync();
}
function wire(nextRoot) {
root = nextRoot;
const fields = inputs();
const applyFields = () => {
const layer = activeLayer();
const geometry = readLayerGeometry(state, layer);
if (!geometry || isLayerPositionLocked(state, layer)) return sync();
const nextX = normalizeLayerCoordinate(fields.x?.value, geometry.x);
const nextY = normalizeLayerCoordinate(fields.y?.value, geometry.y);
if (nextX === geometry.x && nextY === geometry.y) return;
const label = !state.transformActive && !fieldHistorySaved
? `Position "${layer.name || 'layer'}"`
: null;
if (moveTo(nextX, nextY, label)) fieldHistorySaved = true;
};
for (const field of [fields.x, fields.y]) {
if (!field) continue;
field.addEventListener('input', applyFields);
field.addEventListener('change', () => { applyFields(); fieldHistorySaved = false; });
field.addEventListener('blur', () => { fieldHistorySaved = false; sync(); });
field.addEventListener('keydown', (event) => {
if (event.key === 'Enter') field.blur();
if (event.key === 'Escape') { event.preventDefault(); field.blur(); sync(); }
});
}
sync();
}
return { wire, sync, moveTo, nudge, endNudge, trackExternalMove };
}
+510
View File
@@ -0,0 +1,510 @@
/** Parent-linked layer groups with recursively isolated compositing. */
import { drawLayerStack, drawLayerStackAsync } from './layer-clipping.js';
import { renderEffects, renderEffectsAsync } from './effects.js';
function groupMap(editorState) {
return new Map((editorState.layerGroups || []).map(group => [group.id, group]));
}
function breakParentCycles(groups) {
const byId = new Map(groups.map(group => [group.id, group]));
for (const group of groups) {
const visited = new Set([group.id]);
let cursor = group;
while (cursor.parentId) {
if (visited.has(cursor.parentId)) {
group.parentId = null;
break;
}
visited.add(cursor.parentId);
cursor = byId.get(cursor.parentId);
if (!cursor) break;
}
}
}
export function normalizeLayerGroups(editorState) {
const validLayerIds = new Set((editorState.layers || []).map(layer => layer.id));
const claimedLayers = new Set();
const usedGroupIds = new Set();
const normalized = [];
for (const source of editorState.layerGroups || []) {
if (!source || typeof source !== 'object') continue;
let id = String(source.id || `group-${normalized.length + 1}`);
while (usedGroupIds.has(id)) id = `${id}-${normalized.length + 1}`;
usedGroupIds.add(id);
const layerIds = [];
for (const layerId of source.layerIds || []) {
if (validLayerIds.has(layerId) && !claimedLayers.has(layerId)) {
claimedLayers.add(layerId);
layerIds.push(layerId);
}
}
source.id = id;
source.name = String(source.name || `Group ${normalized.length + 1}`);
source.layerIds = layerIds;
source.parentId = source.parentId == null ? null : String(source.parentId);
source.visible = source.visible !== false;
source.opacity = Number.isFinite(Number(source.opacity))
? Math.max(0, Math.min(1, Number(source.opacity)))
: 1;
source.blendMode = source.blendMode || 'source-over';
source.locked = !!source.locked;
source.collapsed = !!source.collapsed;
source.masks = Array.isArray(source.masks) ? source.masks.filter(mask => mask && typeof mask === 'object') : [];
source.effects = Array.isArray(source.effects) ? source.effects : [];
source.activeMaskId = source.masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null;
normalized.push(source);
}
const validGroupIds = new Set(normalized.map(group => group.id));
for (const group of normalized) {
if (group.parentId === group.id || !validGroupIds.has(group.parentId)) group.parentId = null;
}
breakParentCycles(normalized);
// Parent-only groups are valid. Remove only empty leaves, then repeat because
// their removal can make an ancestor empty as well.
let retained = normalized;
while (true) {
const parentIds = new Set(retained.map(group => group.parentId).filter(Boolean));
const next = retained.filter(group => group.layerIds.length || parentIds.has(group.id));
if (next.length === retained.length) break;
const nextIds = new Set(next.map(group => group.id));
for (const group of next) {
if (group.parentId && !nextIds.has(group.parentId)) group.parentId = null;
}
retained = next;
}
editorState.layerGroups = retained;
if (!retained.some(group => group.id === editorState.activeGroupId)) editorState.activeGroupId = null;
return retained;
}
export function groupForLayer(editorState, layerId) {
return normalizeLayerGroups(editorState).find(group => group.layerIds.includes(layerId)) || null;
}
export function groupAncestors(editorState, groupOrId, { includeSelf = false } = {}) {
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const group = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
const result = [];
let cursor = includeSelf ? group : byId.get(group?.parentId);
while (cursor && !result.some(item => item.id === cursor.id)) {
result.push(cursor);
cursor = byId.get(cursor.parentId);
}
return result;
}
export function groupDepth(editorState, groupOrId) {
return groupAncestors(editorState, groupOrId).length;
}
export function groupsForLayer(editorState, layerId) {
const direct = groupForLayer(editorState, layerId);
return direct ? [direct, ...groupAncestors(editorState, direct)] : [];
}
export function allLayerIdsInGroup(editorState, groupOrId) {
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const root = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
if (!root) return [];
const children = new Map();
for (const group of groups) {
if (!group.parentId) continue;
if (!children.has(group.parentId)) children.set(group.parentId, []);
children.get(group.parentId).push(group);
}
const ids = new Set();
const visit = group => {
for (const id of group.layerIds) ids.add(id);
for (const child of children.get(group.id) || []) visit(child);
};
visit(root);
return (editorState.layers || []).filter(layer => ids.has(layer.id)).map(layer => layer.id);
}
export function isLayerEffectivelyLocked(editorState, layer) {
if (!layer) return true;
return !!layer.locked || groupsForLayer(editorState, layer.id).some(group => group.locked);
}
export function normalizeLayerLocks(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return {
pixels: !!source.pixels,
transparency: !!source.transparency,
position: !!source.position,
};
}
export function layerHasAnyLock(layer) {
const locks = normalizeLayerLocks(layer?.locks);
return !!layer?.locked || locks.pixels || locks.transparency || locks.position;
}
export function isLayerPixelLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).pixels;
}
export function isLayerTransparencyLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).transparency;
}
export function isLayerPositionLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).position;
}
export function createGroupFromSelection(editorState, name = null) {
const selected = new Set(editorState.selectedLayerIds || []);
const indexed = (editorState.layers || [])
.map((layer, index) => ({ layer, index }))
.filter(item => selected.has(item.layer.id));
if (indexed.length < 2) return null;
const groups = normalizeLayerGroups(editorState);
const descendantIds = new Map(groups.map(group => [group.id, allLayerIdsInGroup(editorState, group)]));
const fullySelected = new Set(groups
.filter(group => descendantIds.get(group.id).length && descendantIds.get(group.id).every(id => selected.has(id)))
.map(group => group.id));
const selectedGroups = groups.filter(group =>
fullySelected.has(group.id) && !groupAncestors(editorState, group).some(parent => fullySelected.has(parent.id))
);
const covered = new Set(selectedGroups.flatMap(group => descendantIds.get(group.id)));
const directLayerIds = indexed.map(item => item.layer.id).filter(id => !covered.has(id));
const componentParents = [
...selectedGroups.map(group => group.parentId || null),
...directLayerIds.map(id => groupForLayer(editorState, id)?.id || null),
];
const commonParentId = componentParents.length && componentParents.every(id => id === componentParents[0])
? componentParents[0]
: null;
const directSet = new Set(directLayerIds);
for (const group of groups) group.layerIds = group.layerIds.filter(id => !directSet.has(id));
const selectedIds = new Set(indexed.map(item => item.layer.id));
const topIndex = indexed[indexed.length - 1].index;
const remaining = editorState.layers.filter(layer => !selectedIds.has(layer.id));
const insertion = editorState.layers
.slice(0, topIndex + 1)
.filter(layer => !selectedIds.has(layer.id)).length;
const members = indexed.map(item => item.layer);
remaining.splice(insertion, 0, ...members);
editorState.layers = remaining;
const id = `group-${editorState.nextLayerId++}`;
const group = {
id,
name: name || `Group ${groups.length + 1}`,
layerIds: directLayerIds,
parentId: commonParentId,
visible: true,
opacity: 1,
blendMode: 'source-over',
locked: false,
collapsed: false,
masks: [],
activeMaskId: null,
effects: [],
};
for (const child of selectedGroups) child.parentId = id;
editorState.layerGroups.push(group);
normalizeLayerGroups(editorState);
editorState.activeGroupId = id;
editorState.selectedLayerIds = allLayerIdsInGroup(editorState, id);
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
editorState.selectionAnchorId = editorState.activeLayerId;
return groupMap(editorState).get(id) || group;
}
export function ungroupLayers(editorState, groupId) {
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
if (!group || group.masks?.length) return null;
const selectedIds = allLayerIdsInGroup(editorState, group);
const parent = group.parentId ? groupMap(editorState).get(group.parentId) : null;
if (parent) {
const direct = new Set([...parent.layerIds, ...group.layerIds]);
parent.layerIds = (editorState.layers || []).filter(layer => direct.has(layer.id)).map(layer => layer.id);
}
for (const child of editorState.layerGroups) {
if (child.parentId === group.id) child.parentId = group.parentId || null;
}
editorState.layerGroups = editorState.layerGroups.filter(item => item.id !== groupId);
if (editorState.activeGroupId === groupId) editorState.activeGroupId = group.parentId || null;
editorState.selectedLayerIds = selectedIds;
normalizeLayerGroups(editorState);
return group;
}
export function groupSiblingUnits(editorState, parentId = null) {
const groups = normalizeLayerGroups(editorState);
const parent = parentId ? groups.find(group => group.id === parentId) : null;
if (parentId && !parent) return [];
const layerOrder = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const units = [];
const directIds = parent
? parent.layerIds
: (editorState.layers || [])
.filter(layer => !groups.some(group => group.layerIds.includes(layer.id)))
.map(layer => layer.id);
for (const layerId of directIds) {
if (layerOrder.has(layerId)) units.push({ type: 'layer', id: layerId, layerIds: [layerId] });
}
for (const group of groups.filter(item => (item.parentId || null) === (parentId || null))) {
const layerIds = allLayerIdsInGroup(editorState, group);
if (layerIds.length) units.push({ type: 'group', id: group.id, layerIds });
}
return units.sort((a, b) => {
const aIndex = Math.min(...a.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
const bIndex = Math.min(...b.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
return aIndex - bIndex;
});
}
/** Move a complete group subtree among siblings without changing its parent. */
export function reorderGroupAmongSiblings(editorState, groupId, targetIndex) {
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
if (!group) return null;
const parentId = group.parentId || null;
const units = groupSiblingUnits(editorState, parentId);
const currentIndex = units.findIndex(unit => unit.type === 'group' && unit.id === groupId);
if (currentIndex < 0) return null;
const nextIndex = Math.max(0, Math.min(units.length - 1, Math.round(Number(targetIndex))));
if (nextIndex === currentIndex) return { currentIndex, targetIndex: nextIndex, changed: false };
const [moving] = units.splice(currentIndex, 1);
units.splice(nextIndex, 0, moving);
const scopeIds = new Set(units.flatMap(unit => unit.layerIds));
const byId = new Map((editorState.layers || []).map(layer => [layer.id, layer]));
const reordered = units.flatMap(unit => unit.layerIds).map(id => byId.get(id)).filter(Boolean);
let cursor = 0;
editorState.layers = (editorState.layers || []).map(layer =>
scopeIds.has(layer.id) ? reordered[cursor++] : layer
);
return { currentIndex, targetIndex: nextIndex, changed: true, parentId };
}
function groupCanvas(editorState, groupId) {
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
let canvas = editorState.groupCompositeCanvases.get(groupId);
if (!canvas) {
canvas = document.createElement('canvas');
editorState.groupCompositeCanvases.set(groupId, canvas);
}
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
return canvas;
}
export function drawGroupedLayers(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
// A hidden or removed group must not keep advertising its previous pixels to
// the layer panel while the next document composite is being built.
editorState.groupCompositeCanvases.clear();
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const directGroupByLayer = new Map();
const children = new Map();
for (const group of groups) {
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
const parentId = group.parentId || null;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(group);
}
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const boundsMemo = new Map();
const groupBounds = group => {
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
const values = [
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
];
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
boundsMemo.set(group.id, result);
return result;
};
const renderScope = (target, parentId = null) => {
const nodes = [];
for (const layer of editorState.layers || []) {
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
}
}
for (const group of children.get(parentId) || []) {
const bounds = groupBounds(group);
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
}
nodes.sort((a, b) => a.index - b.index);
let layerRun = [];
const flushLayers = () => {
if (!layerRun.length) return;
drawLayerStack(target, editorState, layerRun, renderLayer, renderAdjustment);
layerRun = [];
};
for (const node of nodes) {
if (!shouldContinue()) return;
if (node.type === 'layer') {
layerRun.push(node.layer);
continue;
}
flushLayers();
const group = byId.get(node.group.id);
if (!group?.visible) continue;
const canvas = groupCanvas(editorState, group.id);
const groupCtx = canvas.getContext('2d');
groupCtx.clearRect(0, 0, canvas.width, canvas.height);
renderScope(groupCtx, group.id);
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
if (masks.length) {
groupCtx.globalAlpha = 1;
groupCtx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
}
}
groupCtx.globalAlpha = 1;
groupCtx.filter = 'none';
groupCtx.globalCompositeOperation = 'source-over';
const groupOutput = renderGroup
? renderGroup(canvas, group, shouldContinue)
: (group.effects?.length ? renderEffects(canvas, group.effects, shouldContinue) : canvas);
if (!shouldContinue()) return;
if (groupOutput?.width && groupOutput?.height) {
// Keep the exact final group output for the layer-panel thumbnail. A
// raw member fallback loses masks, blending, and group effects.
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
}
flushLayers();
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
};
renderScope(ctx);
const validIds = new Set(groups.map(group => group.id));
for (const id of editorState.groupCompositeCanvases?.keys?.() || []) {
if (!validIds.has(id)) editorState.groupCompositeCanvases.delete(id);
}
}
/**
* Async group compositor. It deliberately uses fresh group surfaces so an
* older generation cannot overwrite the cached surface used by a newer one.
*/
export async function drawGroupedLayersAsync(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
editorState.groupCompositeCanvases.clear();
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const directGroupByLayer = new Map();
const children = new Map();
for (const group of groups) {
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
const parentId = group.parentId || null;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(group);
}
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const boundsMemo = new Map();
const groupBounds = group => {
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
const values = [
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
];
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
boundsMemo.set(group.id, result);
return result;
};
const renderScope = async (target, parentId = null) => {
const nodes = [];
for (const layer of editorState.layers || []) {
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
}
}
for (const group of children.get(parentId) || []) {
const bounds = groupBounds(group);
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
}
nodes.sort((a, b) => a.index - b.index);
let layerRun = [];
const flushLayers = async () => {
if (!layerRun.length || !shouldContinue()) return;
const run = layerRun;
layerRun = [];
await drawLayerStackAsync(target, editorState, run, renderLayer, renderAdjustment, shouldContinue);
};
for (const node of nodes) {
if (!shouldContinue()) return;
if (node.type === 'layer') {
layerRun.push(node.layer);
continue;
}
await flushLayers();
if (!shouldContinue()) return;
const group = byId.get(node.group.id);
if (!group?.visible) continue;
const canvas = document.createElement('canvas');
canvas.width = editorState.imgWidth;
canvas.height = editorState.imgHeight;
const groupCtx = canvas.getContext('2d');
await renderScope(groupCtx, group.id);
if (!shouldContinue()) return;
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
if (masks.length) {
groupCtx.globalAlpha = 1;
groupCtx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
}
}
groupCtx.globalAlpha = 1;
groupCtx.filter = 'none';
groupCtx.globalCompositeOperation = 'source-over';
const groupOutput = renderGroup
? await renderGroup(canvas, group, shouldContinue)
: (group.effects?.length ? await renderEffectsAsync(canvas, group.effects, shouldContinue) : canvas);
if (!shouldContinue() || !groupOutput) return;
if (groupOutput.width && groupOutput.height) {
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
}
await flushLayers();
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
};
await renderScope(ctx);
}
+119 -2
View File
@@ -53,25 +53,134 @@ export function adjustmentsKey(adj) {
export function defaultAdjParams(type) {
switch (type) {
case 'brightness-contrast': return { brightness: 1, contrast: 1 };
case 'hue-saturation': return { hue: 0, saturation: 1 };
case 'levels': return { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
case 'exposure': return { exposure: 0, offset: 0, gamma: 1 };
case 'white-balance': return { temperature: 0, tint: 0 };
case 'hue-saturation': return { hue: 0, saturation: 1, lightness: 0 };
case 'vibrance': return { vibrance: 0 };
case 'black-white': return { red: 30, green: 59, blue: 11, constant: 0 };
case 'shadows-highlights': return { shadows: 0, highlights: 0 };
case 'levels': return {
channel: 'rgb',
inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255,
channels: {
red: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
green: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
blue: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
},
};
case 'curves': return {
channel: 'rgb',
points: {
rgb: [[0, 0], [255, 255]],
red: [[0, 0], [255, 255]],
green: [[0, 0], [255, 255]],
blue: [[0, 0], [255, 255]],
},
};
case 'color-balance': return {
shadows: { r: 0, g: 0, b: 0 },
midtones: { r: 0, g: 0, b: 0 },
highlights: { r: 0, g: 0, b: 0 },
};
case 'selective-color': return {
range: 'reds',
ranges: Object.fromEntries(
['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks']
.map(name => [name, { cyan: 0, magenta: 0, yellow: 0, black: 0 }]),
),
};
case 'gradient-map': return {
shadows: '#000000',
highlights: '#ffffff',
midpoint: 50,
reverse: false,
};
}
return {};
}
/** Preset names for the retained adjustment popup. */
export function adjustmentPresetOptions(type) {
const options = {
'brightness-contrast': ['Default', 'High Contrast'],
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
'white-balance': ['Default', 'Warm', 'Cool'],
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
'vibrance': ['Default', 'Muted', 'Color Pop'],
'black-white': ['Default', 'High Contrast'],
'shadows-highlights': ['Default', 'Lift Shadows', 'Recover Highlights'],
'levels': ['Default', 'Auto Contrast'],
'curves': ['Default', 'S-Curve'],
'color-balance': ['Default', 'Warm Highlights', 'Cool Shadows'],
'selective-color': ['Default', 'Deep Reds'],
'gradient-map': ['Default', 'Warm Tone', 'Cool Tone'],
};
return options[type] || ['Default'];
}
/** Return a fresh parameter object for a named adjustment preset. */
export function adjustmentPresetParams(type, preset) {
const params = defaultAdjParams(type);
switch (`${type}:${preset}`) {
case 'brightness-contrast:High Contrast':
return { ...params, contrast: 1.28 };
case 'exposure:Lift Exposure':
return { ...params, exposure: 0.45 };
case 'exposure:Lower Exposure':
return { ...params, exposure: -0.45 };
case 'white-balance:Warm':
return { ...params, temperature: 35 };
case 'white-balance:Cool':
return { ...params, temperature: -35 };
case 'hue-saturation:Desaturate':
return { ...params, saturation: 0.35 };
case 'hue-saturation:Boost Color':
return { ...params, saturation: 1.35 };
case 'vibrance:Muted':
return { ...params, vibrance: -30 };
case 'vibrance:Color Pop':
return { ...params, vibrance: 45 };
case 'black-white:High Contrast':
return { ...params, red: 38, green: 54, blue: 8, constant: 0 };
case 'shadows-highlights:Lift Shadows':
return { ...params, shadows: 38 };
case 'shadows-highlights:Recover Highlights':
return { ...params, highlights: -38 };
case 'levels:Auto Contrast':
return { ...params, inBlack: 12, inWhite: 243 };
case 'curves:S-Curve':
return { ...params, points: { ...params.points, rgb: [[0, 12], [76, 64], [178, 194], [255, 243]] } };
case 'color-balance:Warm Highlights':
return { ...params, highlights: { r: 16, g: 4, b: -14 } };
case 'color-balance:Cool Shadows':
return { ...params, shadows: { r: -12, g: 3, b: 16 } };
case 'selective-color:Deep Reds':
return { ...params, ranges: { ...params.ranges, reds: { ...params.ranges.reds, black: -18 } } };
case 'gradient-map:Warm Tone':
return { ...params, shadows: '#21151a', highlights: '#ffd59a' };
case 'gradient-map:Cool Tone':
return { ...params, shadows: '#101c36', highlights: '#d8f2ff' };
default:
return params;
}
}
/** Human-readable name for an adjustment type. */
export function adjLayerLabel(type) {
return {
'brightness-contrast': 'Brightness/Contrast',
'exposure': 'Exposure',
'white-balance': 'White Balance',
'hue-saturation': 'Hue/Saturation',
'vibrance': 'Vibrance',
'black-white': 'Black & White',
'shadows-highlights': 'Shadows/Highlights',
'levels': 'Levels',
'curves': 'Curves',
'color-balance': 'Color Balance',
'selective-color': 'Selective Color',
'gradient-map': 'Gradient Map',
}[type] || type;
}
@@ -83,9 +192,17 @@ export function adjLayerLabel(type) {
*/
export const ADJ_ICONS = {
'brightness-contrast': '<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="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
'exposure': '<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="M13 2 4.5 13H11l-1 9 8.5-11H12z"/></svg>',
'white-balance': '<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 3v10"/><circle cx="12" cy="17" r="4"/><path d="M9 6h3"/></svg>',
'hue-saturation': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="4"/><circle cx="15" cy="9.5" r="4"/><circle cx="15" cy="14.5" r="4"/></svg>',
'vibrance': '<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="M4 18 10 6l4 12 6-12"/><path d="M7 14h10"/></svg>',
'black-white': '<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="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
'shadows-highlights': '<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="8" cy="8" r="4"/><path d="M14 14h7M17.5 10.5v7"/><path d="M5 17h6"/></svg>',
'levels': '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="14" width="3" height="6" rx="0.5"/><rect x="8" y="9" width="3" height="11" rx="0.5"/><rect x="13" y="11" width="3" height="9" rx="0.5"/><rect x="18" y="6" width="3" height="14" rx="0.5"/></svg>',
'curves': '<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="M4 19C7 19 8 5 13 5s4 8 7 8"/><path d="M4 4v16h16"/></svg>',
'color-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12a9 9 0 0 1 9-9v18a9 9 0 0 1-9-9z" fill="currentColor" stroke="none"/></svg>',
'selective-color': '<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="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M21 12h-6M12 21v-6M3 12h6"/></svg>',
'gradient-map': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M12 6v12"/></svg>',
};
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
/** Shared multi-layer selection semantics for panel and canvas tools. */
import { isLayerEffectivelyLocked } from './layer-groups.js';
function existingIds(editorState) {
return new Set((editorState.layers || []).map(layer => layer.id));
}
export function normalizeLayerSelection(editorState) {
const valid = existingIds(editorState);
const selected = [];
for (const id of editorState.selectedLayerIds || []) {
if (valid.has(id) && !selected.includes(id)) selected.push(id);
}
if (valid.has(editorState.activeLayerId) && !selected.includes(editorState.activeLayerId)) {
selected.length = 0;
selected.push(editorState.activeLayerId);
editorState.activeGroupId = null;
}
if (!selected.length && editorState.layers?.length) {
const fallback = editorState.layers[editorState.layers.length - 1];
editorState.activeLayerId = fallback.id;
selected.push(fallback.id);
editorState.activeGroupId = null;
}
editorState.selectedLayerIds = selected;
if (!valid.has(editorState.selectionAnchorId)) {
editorState.selectionAnchorId = editorState.activeLayerId || null;
}
return selected;
}
export function selectedLayers(editorState, { unlockedOnly = false } = {}) {
const ids = new Set(normalizeLayerSelection(editorState));
return (editorState.layers || []).filter(layer =>
ids.has(layer.id) && (!unlockedOnly || !isLayerEffectivelyLocked(editorState, layer))
);
}
export function selectOnlyLayer(editorState, layerId) {
if (!(editorState.layers || []).some(layer => layer.id === layerId)) return [];
editorState.activeLayerId = layerId;
editorState.selectionAnchorId = layerId;
editorState.selectedLayerIds = [layerId];
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function toggleLayerSelection(editorState, layerId) {
const valid = (editorState.layers || []).some(layer => layer.id === layerId);
if (!valid) return normalizeLayerSelection(editorState);
const selected = normalizeLayerSelection(editorState);
const index = selected.indexOf(layerId);
if (index >= 0 && selected.length > 1) {
selected.splice(index, 1);
if (editorState.activeLayerId === layerId) {
editorState.activeLayerId = selected[selected.length - 1];
}
} else if (index < 0) {
selected.push(layerId);
editorState.activeLayerId = layerId;
}
editorState.selectionAnchorId = layerId;
editorState.selectedLayerIds = selected;
editorState.activeGroupId = null;
return selected;
}
export function selectLayerRange(editorState, layerId) {
const layers = editorState.layers || [];
const target = layers.findIndex(layer => layer.id === layerId);
if (target < 0) return normalizeLayerSelection(editorState);
const anchorId = editorState.selectionAnchorId || editorState.activeLayerId || layerId;
const anchor = layers.findIndex(layer => layer.id === anchorId);
if (anchor < 0) return selectOnlyLayer(editorState, layerId);
const start = Math.min(anchor, target);
const end = Math.max(anchor, target);
editorState.selectedLayerIds = layers.slice(start, end + 1).map(layer => layer.id);
editorState.activeLayerId = layerId;
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function selectAllLayers(editorState) {
editorState.selectedLayerIds = (editorState.layers || []).map(layer => layer.id);
if (editorState.selectedLayerIds.length && !editorState.selectedLayerIds.includes(editorState.activeLayerId)) {
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
}
editorState.selectionAnchorId = editorState.activeLayerId || null;
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function selectionLabel(editorState, singular = 'layer') {
const count = normalizeLayerSelection(editorState).length;
return count === 1 ? singular : `${count} layers`;
}
+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;
}
+27
View File
@@ -46,6 +46,33 @@ export function dilateMask(src, px) {
return tmp;
}
/** Invert a mask's reveal amount while preserving partial opacity. */
export function invertMaskInPlace(canvas) {
if (!canvas?.width || !canvas?.height) return false;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let index = 0; index < image.data.length; index += 4) {
const alpha = 255 - image.data[index + 3];
image.data[index] = 255;
image.data[index + 1] = 255;
image.data[index + 2] = 255;
image.data[index + 3] = alpha;
}
ctx.putImageData(image, 0, 0);
return true;
}
export function normalizeMaskDensity(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : 1;
}
export function normalizeMaskFeather(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(200, parsed)) : 0;
}
/**
* Re-derive an inpaint-result layer's alpha from its cached AI image +
+159
View File
@@ -0,0 +1,159 @@
/** Shared-bounds geometry for transforming one or many layers. */
export function selectionBounds(editorState, layers) {
if (!layers?.length) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const layer of layers) {
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
left = Math.min(left, offset.x);
top = Math.min(top, offset.y);
right = Math.max(right, offset.x + Math.max(1, layer.canvas?.width || 1));
bottom = Math.max(bottom, offset.y + Math.max(1, layer.canvas?.height || 1));
}
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
centerX: (left + right) / 2,
centerY: (top + bottom) / 2,
};
}
export function transformedLayerGeometry(snapshot, sourceBounds, target) {
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
const signedScaleX = target.flipH ? -scaleX : scaleX;
const signedScaleY = target.flipV ? -scaleY : scaleY;
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const absCos = Math.abs(cos);
const absSin = Math.abs(sin);
const sourceCenterX = snapshot.offset.x + snapshot.width / 2;
const sourceCenterY = snapshot.offset.y + snapshot.height / 2;
const relativeX = (sourceCenterX - sourceBounds.centerX) * signedScaleX;
const relativeY = (sourceCenterY - sourceBounds.centerY) * signedScaleY;
const centerX = target.centerX + relativeX * cos - relativeY * sin;
const centerY = target.centerY + relativeX * sin + relativeY * cos;
const scaledWidth = snapshot.width * scaleX;
const scaledHeight = snapshot.height * scaleY;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos));
return {
scaleX,
scaleY,
signedScaleX,
signedScaleY,
rotation: target.rotation,
radians,
width,
height,
centerX,
centerY,
offset: {
x: Math.round(centerX - width / 2),
y: Math.round(centerY - height / 2),
},
};
}
export function transformedSelectionBounds(sourceBounds, target) {
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, target.width * cos + target.height * sin);
const height = Math.max(1, target.width * sin + target.height * cos);
return {
x: target.centerX - width / 2,
y: target.centerY - height / 2,
width,
height,
centerX: target.centerX,
centerY: target.centerY,
};
}
function canvasPixels(canvas) {
const width = Number(canvas?.width) || 0;
const height = Number(canvas?.height) || 0;
return Math.max(0, width) * Math.max(0, height);
}
/**
* Build a transform plan without allocating output canvases. The project
* decoder and the live editor share the same limits so a transform cannot
* create a document that the editor would later refuse to reopen.
*/
export function planLayerTransform(editorState, snapshots, sourceBounds, target, limits) {
const maxDimension = Number(limits?.maxDimension) || 32768;
const maxSurfacePixels = Number(limits?.maxSurfacePixels) || 300_000_000;
const numericTarget = [
target?.width, target?.height, target?.rotation,
target?.centerX, target?.centerY,
].every(Number.isFinite);
if (!numericTarget || target.width < 1 || target.height < 1) {
return { ok: false, reason: 'Transform values must be finite positive numbers.' };
}
const planned = [];
const byLayerId = new Map();
for (const snapshot of snapshots || []) {
const geometry = transformedLayerGeometry(snapshot, sourceBounds, target);
if (![geometry.width, geometry.height, geometry.offset.x, geometry.offset.y].every(Number.isFinite)) {
return { ok: false, reason: 'Transform geometry is outside the supported numeric range.' };
}
if (geometry.width > maxDimension || geometry.height > maxDimension) {
return {
ok: false,
reason: `A transformed layer would exceed the ${maxDimension.toLocaleString()} px dimension limit.`,
};
}
const item = { snapshot, geometry };
planned.push(item);
byLayerId.set(snapshot.layer.id, item);
}
let surfacePixels = 0;
const addPixels = pixels => {
surfacePixels += pixels;
return surfacePixels <= maxSurfacePixels;
};
for (const layer of editorState.layers || []) {
const item = byLayerId.get(layer.id);
if (!addPixels(item ? item.geometry.width * item.geometry.height : canvasPixels(layer.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
if (layer.kind === 'placed' && layer.placed?.sourceCanvas && !addPixels(canvasPixels(layer.placed.sourceCanvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
for (const mask of layer.masks || []) {
const maskSnapshot = item?.snapshot.masks?.find(candidate => candidate.mask === mask);
const pixels = maskSnapshot?.linked
? item.geometry.width * item.geometry.height
: canvasPixels(mask.canvas);
if (!addPixels(pixels)) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
if (!addPixels(canvasPixels(mask.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const selection of editorState.savedSelections || []) {
if (!addPixels(canvasPixels(selection.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
if (editorState.wandMask && !addPixels(canvasPixels(editorState.wandMask))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
return { ok: true, surfacePixels, items: planned };
}
+174
View File
@@ -0,0 +1,174 @@
/** Non-destructive image layers backed by immutable source pixels. */
const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
export function normalizeAffine(value, fallback = IDENTITY) {
const source = Array.isArray(value) && value.length === 6 ? value : fallback;
const matrix = source.map(Number);
return matrix.every(Number.isFinite) ? matrix : [...fallback];
}
export function multiplyAffine(left, right) {
const [a, b, c, d, e, f] = normalizeAffine(left);
const [g, h, i, j, k, l] = normalizeAffine(right);
return [
a * g + c * h,
b * g + d * h,
a * i + c * j,
b * i + d * j,
a * k + c * l + e,
b * k + d * l + f,
];
}
export function transformPoint(matrix, x, y) {
const [a, b, c, d, e, f] = normalizeAffine(matrix);
return { x: a * x + c * y + e, y: b * x + d * y + f };
}
export function affineBounds(matrix, width, height) {
const points = [
transformPoint(matrix, 0, 0),
transformPoint(matrix, width, 0),
transformPoint(matrix, width, height),
transformPoint(matrix, 0, height),
];
const minX = Math.floor(Math.min(...points.map(point => point.x)));
const minY = Math.floor(Math.min(...points.map(point => point.y)));
const maxX = Math.ceil(Math.max(...points.map(point => point.x)));
const maxY = Math.ceil(Math.max(...points.map(point => point.y)));
return {
x: minX,
y: minY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
};
}
export function initialPlacedMatrix(sourceWidth, sourceHeight, frame) {
const width = Math.max(1, Number(sourceWidth) || 1);
const height = Math.max(1, Number(sourceHeight) || 1);
return [
Math.max(1, Number(frame?.width) || width) / width,
0,
0,
Math.max(1, Number(frame?.height) || height) / height,
Number(frame?.x) || 0,
Number(frame?.y) || 0,
];
}
/** Matrix that maps the session's document-space frame to its target frame. */
export function frameTransformMatrix(sourceBounds, target) {
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
const sx = target.flipH ? -scaleX : scaleX;
const sy = target.flipV ? -scaleY : scaleY;
const radians = (Number(target.rotation) || 0) * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return multiplyAffine(
[1, 0, 0, 1, target.centerX, target.centerY],
multiplyAffine(
[cos, sin, -sin, cos, 0, 0],
multiplyAffine([sx, 0, 0, sy, 0, 0], [1, 0, 0, 1, -sourceBounds.centerX, -sourceBounds.centerY]),
),
);
}
export function copyCanvas(source) {
if (!source) return null;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Number(source.width) || 1);
canvas.height = Math.max(1, Number(source.height) || 1);
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
export function createPlacedData(sourceCanvas, matrix, sourceName = 'Placed image') {
if (!sourceCanvas?.width || !sourceCanvas?.height) throw new Error('Placed source is empty.');
return {
sourceCanvas: copyCanvas(sourceCanvas),
sourceWidth: sourceCanvas.width,
sourceHeight: sourceCanvas.height,
sourceName: String(sourceName || 'Placed image').slice(0, 200),
matrix: normalizeAffine(matrix),
};
}
export function clonePlacedData(placed, { copySource = false } = {}) {
if (!placed?.sourceCanvas) return null;
return {
sourceCanvas: copySource ? copyCanvas(placed.sourceCanvas) : placed.sourceCanvas,
sourceWidth: placed.sourceWidth || placed.sourceCanvas.width,
sourceHeight: placed.sourceHeight || placed.sourceCanvas.height,
sourceName: placed.sourceName || 'Placed image',
matrix: normalizeAffine(placed.matrix),
};
}
/** Render from the immutable source and return the layer's document offset. */
export function renderPlacedLayer(layer, placed = layer?.placed) {
if (!layer || !placed?.sourceCanvas) return null;
const sourceWidth = placed.sourceWidth || placed.sourceCanvas.width;
const sourceHeight = placed.sourceHeight || placed.sourceCanvas.height;
const bounds = affineBounds(placed.matrix, sourceWidth, sourceHeight);
const canvas = layer.canvas || document.createElement('canvas');
canvas.width = bounds.width;
canvas.height = bounds.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const [a, b, c, d, e, f] = normalizeAffine(placed.matrix);
ctx.setTransform(a, b, c, d, e - bounds.x, f - bounds.y);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(placed.sourceCanvas, 0, 0, sourceWidth, sourceHeight);
ctx.setTransform(1, 0, 0, 1, 0, 0);
layer.canvas = canvas;
layer.ctx = ctx;
layer.kind = 'placed';
layer.placed = placed;
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
return { canvas, offset: { x: bounds.x, y: bounds.y }, bounds };
}
export function transformPlacedData(placed, documentTransform) {
return {
...clonePlacedData(placed),
matrix: multiplyAffine(documentTransform, placed.matrix),
};
}
export function translatePlacedData(placed, dx, dy) {
return transformPlacedData(placed, [1, 0, 0, 1, Number(dx) || 0, Number(dy) || 0]);
}
/** Replace pixels while preserving the exact four document-space corners. */
export function replacePlacedSource(layer, sourceCanvas, sourceName = 'Placed image') {
if (layer?.kind !== 'placed' || !layer.placed?.sourceCanvas) return null;
if (!sourceCanvas?.width || !sourceCanvas?.height) throw new Error('Replacement source is empty.');
const oldWidth = layer.placed.sourceWidth || layer.placed.sourceCanvas.width;
const oldHeight = layer.placed.sourceHeight || layer.placed.sourceCanvas.height;
const [a, b, c, d, e, f] = normalizeAffine(layer.placed.matrix);
layer.placed = createPlacedData(sourceCanvas, [
a * oldWidth / sourceCanvas.width,
b * oldWidth / sourceCanvas.width,
c * oldHeight / sourceCanvas.height,
d * oldHeight / sourceCanvas.height,
e,
f,
], sourceName);
return renderPlacedLayer(layer);
}
export function rasterizePlacedLayer(layer) {
if (layer?.kind !== 'placed') return false;
layer.kind = 'raster';
layer.placed = null;
return true;
}
+253
View File
@@ -0,0 +1,253 @@
/** Rulers, grid, and document-space guides. These are viewport overlays only. */
import { state } from './state.js';
export const RULER_SIZE = 18;
export function chooseRulerStep(zoom, minimumPixels = 48) {
const safeZoom = Math.max(0.0001, Number(zoom) || 1);
const target = minimumPixels / safeZoom;
const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, target)));
for (const factor of [1, 2, 5, 10]) {
const step = magnitude * factor;
if (step >= target) return step;
}
return magnitude * 10;
}
export function pointerToDocument(clientX, clientY, canvasRect, width, height) {
const scaleX = canvasRect.width / Math.max(1, width);
const scaleY = canvasRect.height / Math.max(1, height);
return {
x: (clientX - canvasRect.left) / Math.max(scaleX, 0.0001),
y: (clientY - canvasRect.top) / Math.max(scaleY, 0.0001),
};
}
function normalizedGuides() {
if (!state.guides || typeof state.guides !== 'object') state.guides = { vertical: [], horizontal: [] };
state.guides.vertical = Array.isArray(state.guides.vertical) ? state.guides.vertical : [];
state.guides.horizontal = Array.isArray(state.guides.horizontal) ? state.guides.horizontal : [];
return state.guides;
}
export function createPrecisionGuides({ canvasArea, composite, saveState, schedulePersist }) {
const corner = document.createElement('div');
corner.className = 'ge-ruler-corner';
const horizontal = document.createElement('canvas');
horizontal.className = 'ge-ruler ge-ruler-horizontal';
horizontal.title = 'Drag down to add a horizontal guide';
const vertical = document.createElement('canvas');
vertical.className = 'ge-ruler ge-ruler-vertical';
vertical.title = 'Drag right to add a vertical guide';
canvasArea.append(corner, horizontal, vertical);
let drag = null;
let frame = null;
function themeColors() {
const styles = getComputedStyle(state.container || canvasArea);
return {
background: styles.getPropertyValue('--panel').trim() || '#111',
foreground: styles.getPropertyValue('--fg').trim() || '#ddd',
border: styles.getPropertyValue('--border').trim() || '#333',
};
}
function prepareRuler(canvas) {
const rect = canvas.getBoundingClientRect();
const ratio = Math.max(1, window.devicePixelRatio || 1);
const width = Math.max(1, Math.round(rect.width * ratio));
const height = Math.max(1, Math.round(rect.height * ratio));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
return { ctx, rect };
}
function drawRuler(canvas, orientation) {
if (!state.rulersVisible || !state.mainCanvas) return;
const { ctx, rect } = prepareRuler(canvas);
const areaRect = canvasArea.getBoundingClientRect();
const imageRect = state.mainCanvas.getBoundingClientRect();
const colors = themeColors();
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = colors.border;
ctx.beginPath();
if (orientation === 'horizontal') {
ctx.moveTo(0, rect.height - 0.5); ctx.lineTo(rect.width, rect.height - 0.5);
} else {
ctx.moveTo(rect.width - 0.5, 0); ctx.lineTo(rect.width - 0.5, rect.height);
}
ctx.stroke();
const scale = orientation === 'horizontal'
? imageRect.width / Math.max(1, state.imgWidth)
: imageRect.height / Math.max(1, state.imgHeight);
const origin = orientation === 'horizontal'
? imageRect.left - areaRect.left - RULER_SIZE
: imageRect.top - areaRect.top - RULER_SIZE;
const limit = orientation === 'horizontal' ? state.imgWidth : state.imgHeight;
const major = chooseRulerStep(scale);
const minor = major / 5;
ctx.strokeStyle = colors.foreground;
ctx.fillStyle = colors.foreground;
ctx.globalAlpha = 0.62;
ctx.lineWidth = 1;
ctx.font = '8px ui-monospace, monospace';
ctx.textBaseline = 'top';
for (let value = 0; value <= limit + 0.001; value += minor) {
const pixel = origin + value * scale;
const rulerLimit = orientation === 'horizontal' ? rect.width : rect.height;
if (pixel < -2 || pixel > rulerLimit + 2) continue;
const isMajor = Math.round(value / minor) % 5 === 0;
if (orientation === 'horizontal') {
ctx.beginPath();
ctx.moveTo(Math.round(pixel) + 0.5, rect.height);
ctx.lineTo(Math.round(pixel) + 0.5, rect.height - (isMajor ? 9 : 4));
ctx.stroke();
if (isMajor) ctx.fillText(String(Math.round(value)), pixel + 2, 1);
} else {
ctx.beginPath();
ctx.moveTo(rect.width, Math.round(pixel) + 0.5);
ctx.lineTo(rect.width - (isMajor ? 9 : 4), Math.round(pixel) + 0.5);
ctx.stroke();
if (isMajor) {
ctx.save();
ctx.translate(1, pixel - 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(String(Math.round(value)), 0, 0);
ctx.restore();
}
}
}
ctx.globalAlpha = 1;
}
function redrawRulers() {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
canvasArea.classList.toggle('ge-rulers-visible', !!state.rulersVisible);
corner.hidden = !state.rulersVisible;
horizontal.hidden = !state.rulersVisible;
vertical.hidden = !state.rulersVisible;
if (state.rulersVisible) {
drawRuler(horizontal, 'horizontal');
drawRuler(vertical, 'vertical');
}
});
}
function drawDocumentOverlay(ctx) {
if (!ctx || !state.imgWidth || !state.imgHeight) return;
ctx.save();
if (state.gridVisible) {
const base = Math.max(2, Number(state.gridSize) || 16);
const multiplier = Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
const spacing = base * multiplier;
ctx.lineWidth = 1 / state.zoom;
for (let x = spacing; x < state.imgWidth; x += spacing) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight);
ctx.strokeStyle = Math.round(x / spacing) % 4 === 0
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
ctx.stroke();
}
for (let y = spacing; y < state.imgHeight; y += spacing) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.imgWidth, y);
ctx.strokeStyle = Math.round(y / spacing) % 4 === 0
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
ctx.stroke();
}
}
const guides = normalizedGuides();
ctx.strokeStyle = 'rgba(78, 190, 255, 0.92)';
ctx.lineWidth = 1 / state.zoom;
ctx.setLineDash([]);
for (const x of guides.vertical) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight); ctx.stroke();
}
for (const y of guides.horizontal) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.imgWidth, y); ctx.stroke();
}
if (drag?.moved && Number.isFinite(drag.position)) {
ctx.strokeStyle = 'rgba(120, 215, 255, 1)';
if (drag.orientation === 'vertical') {
ctx.beginPath(); ctx.moveTo(drag.position, 0); ctx.lineTo(drag.position, state.imgHeight); ctx.stroke();
} else {
ctx.beginPath(); ctx.moveTo(0, drag.position); ctx.lineTo(state.imgWidth, drag.position); ctx.stroke();
}
}
ctx.restore();
}
function updateDrag(event) {
if (!drag || !state.mainCanvas) return;
const point = pointerToDocument(
event.clientX, event.clientY,
state.mainCanvas.getBoundingClientRect(),
state.imgWidth, state.imgHeight,
);
const delta = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
drag.moved = drag.moved || delta > 3;
drag.position = drag.orientation === 'vertical'
? Math.max(0, Math.min(state.imgWidth, Math.round(point.x)))
: Math.max(0, Math.min(state.imgHeight, Math.round(point.y)));
if (drag.moved) composite();
}
function beginGuide(event, orientation) {
if (event.button != null && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
drag = { orientation, startX: event.clientX, startY: event.clientY, moved: false, position: 0 };
const move = next => updateDrag(next);
const cancel = () => finish(false);
const finish = (commit, next) => {
if (next) updateDrag(next);
if (commit && drag?.moved) {
const values = normalizedGuides()[drag.orientation];
if (!values.some(value => Math.abs(value - drag.position) < 0.5)) {
saveState?.('Add guide');
values.push(drag.position);
values.sort((a, b) => a - b);
schedulePersist?.();
}
}
drag = null;
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', end);
window.removeEventListener('pointercancel', cancel);
composite();
redrawRulers();
};
const end = next => finish(true, next);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', end);
window.addEventListener('pointercancel', cancel);
}
horizontal.addEventListener('pointerdown', event => beginGuide(event, 'horizontal'));
vertical.addEventListener('pointerdown', event => beginGuide(event, 'vertical'));
canvasArea.addEventListener('scroll', redrawRulers, { passive: true });
const observer = window.ResizeObserver ? new ResizeObserver(redrawRulers) : null;
observer?.observe(canvasArea);
redrawRulers();
return {
drawDocumentOverlay,
redrawRulers,
syncVisibility() { redrawRulers(); composite(); },
clearGuides() {
const guides = normalizedGuides();
if (!guides.vertical.length && !guides.horizontal.length) return;
saveState?.('Clear guides');
state.guides = { vertical: [], horizontal: [] };
schedulePersist?.();
composite();
redrawRulers();
},
};
}
+15
View File
@@ -0,0 +1,15 @@
/** Cooperative render generations shared by synchronous and future async renderers. */
export function createRenderGeneration() {
let generation = 0;
return {
begin() {
const id = ++generation;
return {
id,
isCurrent: () => id === generation,
cancel: () => { if (id === generation) generation += 1; },
};
},
invalidate() { generation += 1; },
};
}
+294
View File
@@ -0,0 +1,294 @@
/** Shared selection-mask geometry and compositing helpers. */
import { dilateMask } from './mask-utils.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
export function normalizeSelectionRect(start, end, width, height, square = false) {
let dx = end.x - start.x;
let dy = end.y - start.y;
if (square) {
const size = Math.min(Math.abs(dx), Math.abs(dy));
dx = Math.sign(dx || 1) * size;
dy = Math.sign(dy || 1) * size;
}
const x1 = Math.max(0, Math.min(width, start.x));
const y1 = Math.max(0, Math.min(height, start.y));
const x2 = Math.max(0, Math.min(width, start.x + dx));
const y2 = Math.max(0, Math.min(height, start.y + dy));
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
};
}
export function normalizeConstrainedSelectionRect(start, end, width, height, options = {}) {
const mode = ['ratio', 'size'].includes(options.mode) ? options.mode : 'free';
if (mode === 'free') return normalizeSelectionRect(start, end, width, height, !!options.square);
const sx = Math.max(0, Math.min(width, Number(start.x) || 0));
const sy = Math.max(0, Math.min(height, Number(start.y) || 0));
if (mode === 'size') {
const w = Math.max(1, Math.min(width, Math.round(Number(options.fixedWidth) || 1)));
const h = Math.max(1, Math.min(height, Math.round(Number(options.fixedHeight) || 1)));
return {
x: Math.max(0, Math.min(width - w, sx)),
y: Math.max(0, Math.min(height - h, sy)),
w,
h,
};
}
const ratioWidth = Math.max(0.01, Number(options.ratioWidth) || 1);
const ratioHeight = Math.max(0.01, Number(options.ratioHeight) || 1);
const ratio = ratioWidth / ratioHeight;
const dx = (Number(end.x) || 0) - sx;
const dy = (Number(end.y) || 0) - sy;
const signX = dx < 0 ? -1 : 1;
const signY = dy < 0 ? -1 : 1;
let w;
let h;
if (Math.abs(dx) / Math.max(Math.abs(dy), 0.0001) >= ratio) {
w = Math.abs(dx);
h = w / ratio;
} else {
h = Math.abs(dy);
w = h * ratio;
}
const availableW = signX < 0 ? sx : width - sx;
const availableH = signY < 0 ? sy : height - sy;
const scale = Math.min(1, availableW / Math.max(w, 0.0001), availableH / Math.max(h, 0.0001));
w *= scale;
h *= scale;
return {
x: signX < 0 ? sx - w : sx,
y: signY < 0 ? sy - h : sy,
w,
h,
};
}
export function createMarqueeMask(width, height, rect, shape = 'rectangle') {
const canvas = makeCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
if (shape === 'ellipse') {
ctx.beginPath();
ctx.ellipse(
rect.x + rect.w / 2,
rect.y + rect.h / 2,
rect.w / 2,
rect.h / 2,
0,
0,
Math.PI * 2,
);
ctx.fill();
} else {
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
}
return canvas;
}
export function mergeSelectionMasks(current, incoming, mode = 'replace') {
if (!current || mode === 'replace') return incoming;
const merged = makeCanvas(incoming.width, incoming.height);
const ctx = merged.getContext('2d');
ctx.drawImage(current, 0, 0);
if (mode === 'subtract') ctx.globalCompositeOperation = 'destination-out';
else if (mode === 'intersect') ctx.globalCompositeOperation = 'destination-in';
else ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(incoming, 0, 0);
ctx.globalCompositeOperation = 'source-over';
return merged;
}
function copyMask(mask) {
if (!mask) return null;
const canvas = makeCanvas(mask.width, mask.height);
canvas.getContext('2d').drawImage(mask, 0, 0);
return canvas;
}
function blurMask(mask, radius) {
if (!mask || radius <= 0) return copyMask(mask);
const canvas = makeCanvas(mask.width, mask.height);
const ctx = canvas.getContext('2d');
ctx.filter = `blur(${Math.max(0, radius)}px)`;
ctx.drawImage(mask, 0, 0);
ctx.filter = 'none';
return canvas;
}
function thresholdMask(mask, threshold = 128) {
const canvas = copyMask(mask);
const ctx = canvas.getContext('2d');
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < image.data.length; i += 4) {
const alpha = image.data[i + 3] >= threshold ? 255 : 0;
image.data[i] = alpha;
image.data[i + 1] = alpha;
image.data[i + 2] = alpha;
image.data[i + 3] = alpha;
}
ctx.putImageData(image, 0, 0);
return canvas;
}
/** Build a non-destructive preview for the Select > Refine workflow. */
export function refineSelectionMask(mask, options = {}) {
if (!mask) return null;
const expand = Math.max(-100, Math.min(100, Math.round(Number(options.expand) || 0)));
const smooth = Math.max(0, Math.min(100, Math.round(Number(options.smooth) || 0)));
const border = Math.max(0, Math.min(200, Math.round(Number(options.border) || 0)));
const feather = Math.max(0, Math.min(200, Number(options.feather) || 0));
let result = expand ? dilateMask(mask, expand) : copyMask(mask);
if (smooth) result = thresholdMask(blurMask(result, smooth), 128);
if (border) {
const outer = dilateMask(result, Math.ceil(border / 2));
const inner = dilateMask(result, -Math.floor(border / 2));
const ctx = outer.getContext('2d');
ctx.globalCompositeOperation = 'destination-out';
ctx.drawImage(inner, 0, 0);
ctx.globalCompositeOperation = 'source-over';
result = outer;
}
return feather ? blurMask(result, feather) : result;
}
export function selectionMaskToDocument(mask, space, layerOffset, width, height) {
if (!mask) return null;
if (space === 'document' && mask.width === width && mask.height === height) return mask;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(
mask,
space === 'document' ? 0 : (layerOffset?.x || 0),
space === 'document' ? 0 : (layerOffset?.y || 0),
);
return canvas;
}
export function selectionMaskForLayer(mask, space, layerOffset, width, height) {
if (!mask) return null;
if (space !== 'document' && mask.width === width && mask.height === height) return mask;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(
mask,
space === 'document' ? -(layerOffset?.x || 0) : 0,
space === 'document' ? -(layerOffset?.y || 0) : 0,
);
return canvas;
}
export function selectionMaskContains(mask, x, y, threshold = 1) {
if (!mask || x < 0 || y < 0 || x >= mask.width || y >= mask.height) return false;
try {
return mask.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data[3] >= threshold;
} catch {
return false;
}
}
export function selectionMaskBounds(mask, threshold = 1) {
if (!mask?.width || !mask?.height) return null;
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return null; }
let left = mask.width;
let top = mask.height;
let right = -1;
let bottom = -1;
for (let y = 0; y < mask.height; y += 1) {
for (let x = 0; x < mask.width; x += 1) {
if (data[(y * mask.width + x) * 4 + 3] < threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
}
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
}
export function translateSelectionMask(mask, dx, dy, width = mask?.width, height = mask?.height) {
if (!mask) return null;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(mask, Math.round(dx), Math.round(dy));
return canvas;
}
/**
* Render a document-space selection through one affine transform. The source
* mask is never mutated, so callers can rebuild every live preview from the
* same pixels instead of accumulating interpolation damage.
*/
export function transformSelectionMask(mask, sourceBounds, target, width = mask?.width, height = mask?.height) {
if (!mask || !sourceBounds || !target) return null;
const sourceWidth = Math.max(1, Number(sourceBounds.width) || 1);
const sourceHeight = Math.max(1, Number(sourceBounds.height) || 1);
const targetWidth = Math.max(1, Number(target.width) || 1);
const targetHeight = Math.max(1, Number(target.height) || 1);
const canvas = makeCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
ctx.translate(Number(target.centerX) || 0, Number(target.centerY) || 0);
ctx.rotate(((Number(target.rotation) || 0) * Math.PI) / 180);
ctx.scale(target.flipH ? -1 : 1, target.flipV ? -1 : 1);
ctx.drawImage(
mask,
sourceBounds.x,
sourceBounds.y,
sourceWidth,
sourceHeight,
-targetWidth / 2,
-targetHeight / 2,
targetWidth,
targetHeight,
);
ctx.restore();
return canvas;
}
export function selectionBoundaryPixels(mask, threshold = 128) {
if (!mask?.width || !mask?.height) return [];
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return []; }
const selected = (x, y) => x >= 0 && y >= 0 && x < mask.width && y < mask.height
&& data[(y * mask.width + x) * 4 + 3] >= threshold;
const pixels = [];
for (let y = 0; y < mask.height; y += 1) {
for (let x = 0; x < mask.width; x += 1) {
if (!selected(x, y)) continue;
if (!selected(x - 1, y) || !selected(x + 1, y) || !selected(x, y - 1) || !selected(x, y + 1)) {
pixels.push(y * mask.width + x);
}
}
}
return pixels;
}
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0) {
if (!ctx || !pixels?.length || !width || !height) return;
const image = ctx.createImageData(width, height);
for (const index of pixels) {
const x = index % width;
const y = Math.floor(index / width);
const light = ((x + y + phase) % 8) < 4;
const offset = index * 4;
image.data[offset] = light ? 255 : 0;
image.data[offset + 1] = light ? 255 : 0;
image.data[offset + 2] = light ? 255 : 0;
image.data[offset + 3] = 255;
}
ctx.putImageData(image, 0, 0);
}
+6
View File
@@ -0,0 +1,6 @@
export function selectionModeForEvent(event, fallback = 'replace') {
if (event.shiftKey && event.altKey) return 'intersect';
if (event.shiftKey) return 'add';
if (event.altKey) return 'subtract';
return fallback;
}
+21
View File
@@ -0,0 +1,21 @@
/* Encode a batch of canvas bitmaps without blocking the editor thread. */
self.onmessage = async event => {
const bitmaps = Array.isArray(event.data?.bitmaps) ? event.data.bitmaps : [];
try {
const blobs = [];
for (const bitmap of bitmaps) {
if (!bitmap?.width || !bitmap?.height) {
blobs.push(null);
continue;
}
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
blobs.push(await canvas.convertToBlob({ type: 'image/png' }));
bitmap.close?.();
}
self.postMessage({ blobs });
} catch (error) {
for (const bitmap of bitmaps) bitmap?.close?.();
self.postMessage({ error: String(error?.message || error) });
}
};
+196
View File
@@ -0,0 +1,196 @@
/** Retained vector-shape model and raster-cache renderer. */
import { normalizeGradientStops } from './gradient-stops.js';
export { normalizeGradientStops } from './gradient-stops.js';
export const DEFAULT_SHAPE_DATA = Object.freeze({
type: 'rectangle',
width: 180,
height: 120,
fillColor: '#ffffff',
fillType: 'solid',
gradientStart: '#ffffff',
gradientMid: '#808080',
gradientMidEnabled: false,
gradientMidPosition: 50,
gradientEnd: '#000000',
gradientAngle: 0,
strokeColor: '#111111',
strokeWidth: 2,
cornerRadius: 0,
sides: 5,
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
});
function finite(value, fallback, min = -Infinity, max = Infinity) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
export function normalizeShapeData(value = {}) {
const transform = value.transform || {};
const gradientStart = String(value.gradientStart || DEFAULT_SHAPE_DATA.gradientStart);
const gradientMid = String(value.gradientMid || DEFAULT_SHAPE_DATA.gradientMid);
const gradientMidEnabled = !!value.gradientMidEnabled;
const gradientMidPosition = finite(value.gradientMidPosition, DEFAULT_SHAPE_DATA.gradientMidPosition, 1, 99);
const gradientEnd = String(value.gradientEnd || DEFAULT_SHAPE_DATA.gradientEnd);
return {
type: ['rectangle', 'ellipse', 'line', 'polygon'].includes(value.type) ? value.type : 'rectangle',
width: finite(value.width, DEFAULT_SHAPE_DATA.width, 1, 10000),
height: finite(value.height, DEFAULT_SHAPE_DATA.height, 1, 10000),
fillColor: String(value.fillColor || DEFAULT_SHAPE_DATA.fillColor),
fillType: value.fillType === 'linear-gradient' ? 'linear-gradient' : DEFAULT_SHAPE_DATA.fillType,
gradientStart,
gradientMid,
gradientMidEnabled,
gradientMidPosition,
gradientEnd,
gradientStops: normalizeGradientStops(value.gradientStops, {
start: gradientStart,
mid: gradientMid,
midEnabled: gradientMidEnabled,
midPosition: gradientMidPosition,
end: gradientEnd,
}),
gradientAngle: finite(value.gradientAngle, DEFAULT_SHAPE_DATA.gradientAngle, -36000, 36000),
strokeColor: String(value.strokeColor || DEFAULT_SHAPE_DATA.strokeColor),
strokeWidth: finite(value.strokeWidth, DEFAULT_SHAPE_DATA.strokeWidth, 0, 500),
cornerRadius: finite(value.cornerRadius, DEFAULT_SHAPE_DATA.cornerRadius, 0, 5000),
sides: Math.round(finite(value.sides, DEFAULT_SHAPE_DATA.sides, 3, 24)),
transform: {
scaleX: finite(transform.scaleX, 1, 0.01, 100),
scaleY: finite(transform.scaleY, 1, 0.01, 100),
rotation: finite(transform.rotation, 0, -36000, 36000),
flipH: !!transform.flipH,
flipV: !!transform.flipV,
},
};
}
function shapePath(ctx, shape, padding) {
const x = padding;
const y = padding;
const width = shape.width;
const height = shape.height;
ctx.beginPath();
if (shape.type === 'ellipse') {
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
} else if (shape.type === 'line') {
ctx.moveTo(x, y + height);
ctx.lineTo(x + width, y);
} else if (shape.type === 'polygon') {
const cx = x + width / 2;
const cy = y + height / 2;
for (let index = 0; index < shape.sides; index += 1) {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / shape.sides;
const px = cx + Math.cos(angle) * width / 2;
const py = cy + Math.sin(angle) * height / 2;
if (!index) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
} else {
const radius = Math.min(shape.cornerRadius, width / 2, height / 2);
if (ctx.roundRect) ctx.roundRect(x, y, width, height, radius);
else ctx.rect(x, y, width, height);
}
}
function baseShapeCanvas(shape) {
const padding = Math.ceil(Math.max(2, shape.strokeWidth / 2 + 2));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.ceil(shape.width + padding * 2));
canvas.height = Math.max(1, Math.ceil(shape.height + padding * 2));
const ctx = canvas.getContext('2d');
shapePath(ctx, shape, padding);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
if (shape.type !== 'line' && shape.fillColor !== 'transparent') {
if (shape.fillType === 'linear-gradient' && typeof ctx.createLinearGradient === 'function') {
const radians = shape.gradientAngle * Math.PI / 180;
const dx = Math.cos(radians) * shape.width / 2;
const dy = Math.sin(radians) * shape.height / 2;
const gradient = ctx.createLinearGradient(
padding + shape.width / 2 - dx,
padding + shape.height / 2 - dy,
padding + shape.width / 2 + dx,
padding + shape.height / 2 + dy,
);
for (const stop of shape.gradientStops) {
gradient.addColorStop(stop.position / 100, stop.color);
}
ctx.fillStyle = gradient;
} else {
ctx.fillStyle = shape.fillColor;
}
ctx.fill();
}
if (shape.strokeWidth > 0) {
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth;
ctx.stroke();
}
return canvas;
}
export function renderShapeLayer(layer) {
if (!layer?.canvas || !layer?.ctx) return null;
layer.kind = 'shape';
layer.shape = normalizeShapeData(layer.shape);
const base = baseShapeCanvas(layer.shape);
const transform = layer.shape.transform;
const scaledWidth = Math.max(1, base.width * transform.scaleX);
const scaledHeight = Math.max(1, base.height * transform.scaleY);
const radians = transform.rotation * Math.PI / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, Math.ceil(scaledWidth * cos + scaledHeight * sin));
const height = Math.max(1, Math.ceil(scaledWidth * sin + scaledHeight * cos));
const rendered = document.createElement('canvas');
rendered.width = width;
rendered.height = height;
const ctx = rendered.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(width / 2, height / 2);
ctx.rotate(radians);
ctx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
ctx.drawImage(base, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
layer.canvas.width = width;
layer.canvas.height = height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.clearRect(0, 0, width, height);
layer.ctx.drawImage(rendered, 0, 0);
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
return layer.canvas;
}
export function rasterizeShapeLayer(layer) {
if (!layer || layer.kind !== 'shape') return false;
layer.kind = 'raster';
layer.shape = null;
return true;
}
export function scaleShapeLayer(layer, scaleX, scaleY) {
layer.shape = normalizeShapeData(layer.shape);
layer.shape.transform.scaleX *= scaleX;
layer.shape.transform.scaleY *= scaleY;
return renderShapeLayer(layer);
}
export function rotateShapeLayer(layer, degrees) {
layer.shape = normalizeShapeData(layer.shape);
layer.shape.transform.rotation += degrees;
return renderShapeLayer(layer);
}
export function flipShapeLayer(layer, axis) {
layer.shape = normalizeShapeData(layer.shape);
if (axis === 'h') layer.shape.transform.flipH = !layer.shape.transform.flipH;
else layer.shape.transform.flipV = !layer.shape.transform.flipV;
return renderShapeLayer(layer);
}
+102 -8
View File
@@ -1,3 +1,5 @@
import { transformFrameGeometry } from './transform-frame-geometry.js';
/**
* Snap-while-dragging: when the move tool drags a layer near another
* layer's edge or the canvas centre/edges, gently lock the proposed
@@ -19,6 +21,8 @@
* canvasW: number,
* canvasH: number,
* otherLayers: Array<{visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x:number, y:number}}>,
* verticalGuides?: number[], horizontalGuides?: number[],
* snapToGrid?: boolean, gridSize?: number,
* }} ctx
* @returns {{x: number, y: number, guides: Array}}
*/
@@ -39,6 +43,12 @@ export function computeSnap(layer, nx, ny, ctx) {
{ y: ch, label: 'canvas-b' },
{ y: ch / 2, label: 'canvas-cy' },
];
for (const x of Array.isArray(ctx.verticalGuides) ? ctx.verticalGuides : []) {
if (Number.isFinite(Number(x))) vTargets.push({ x: Number(x), label: 'guide-v' });
}
for (const y of Array.isArray(ctx.horizontalGuides) ? ctx.horizontalGuides : []) {
if (Number.isFinite(Number(y))) hTargets.push({ y: Number(y), label: 'guide-h' });
}
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
for (const other of otherLayers) {
if (!other.visible || other.id === layer.id) continue;
@@ -64,6 +74,15 @@ export function computeSnap(layer, nx, ny, ctx) {
bestX = { snapTo: t.x, src, target: t };
}
}
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
const grid = Number(ctx.gridSize);
const target = Math.round(val / grid) * grid;
const d = Math.abs(target - val);
if (d < SNAP_PX && d < bestDx) {
bestDx = d;
bestX = { snapTo: target, src, target: { x: target, label: 'grid' } };
}
}
}
for (const [src, val] of Object.entries(myEdgesY)) {
for (const t of hTargets) {
@@ -73,6 +92,15 @@ export function computeSnap(layer, nx, ny, ctx) {
bestY = { snapTo: t.y, src, target: t };
}
}
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
const grid = Number(ctx.gridSize);
const target = Math.round(val / grid) * grid;
const d = Math.abs(target - val);
if (d < SNAP_PX && d < bestDy) {
bestDy = d;
bestY = { snapTo: target, src, target: { y: target, label: 'grid' } };
}
}
}
const guides = [];
@@ -92,18 +120,84 @@ export function computeSnap(layer, nx, ny, ctx) {
return { x: snappedX, y: snappedY, guides };
}
/** Snap a rotated transform frame by its visible bounds and center. */
export function computeTransformSnap(frame, proposedCenter, ctx = {}) {
const zoom = Number.isFinite(Number(ctx.zoom)) ? Number(ctx.zoom) : 1;
const threshold = 6 / Math.max(zoom, 0.0001);
const geometry = transformFrameGeometry({
...frame,
centerX: proposedCenter.x,
centerY: proposedCenter.y,
});
const points = Object.values(geometry.corners);
const minX = Math.min(...points.map(point => point.x));
const maxX = Math.max(...points.map(point => point.x));
const minY = Math.min(...points.map(point => point.y));
const maxY = Math.max(...points.map(point => point.y));
const sourcesX = [minX, geometry.centerX, maxX];
const sourcesY = [minY, geometry.centerY, maxY];
const vertical = [0, Number(ctx.canvasW) || 0, (Number(ctx.canvasW) || 0) / 2];
const horizontal = [0, Number(ctx.canvasH) || 0, (Number(ctx.canvasH) || 0) / 2];
for (const value of Array.isArray(ctx.verticalGuides) ? ctx.verticalGuides : []) {
if (Number.isFinite(Number(value))) vertical.push(Number(value));
}
for (const value of Array.isArray(ctx.horizontalGuides) ? ctx.horizontalGuides : []) {
if (Number.isFinite(Number(value))) horizontal.push(Number(value));
}
for (const layer of Array.isArray(ctx.otherLayers) ? ctx.otherLayers : []) {
if (!layer?.visible || !layer.canvas) continue;
const offset = layer.offset || { x: 0, y: 0 };
const left = Number(offset.x) || 0;
const top = Number(offset.y) || 0;
const right = left + (Number(layer.canvas.width) || 0);
const bottom = top + (Number(layer.canvas.height) || 0);
vertical.push(left, (left + right) / 2, right);
horizontal.push(top, (top + bottom) / 2, bottom);
}
let bestX = null;
let bestY = null;
const consider = (source, target, current) => {
const delta = target - source;
if (Math.abs(delta) >= threshold) return current;
return !current || Math.abs(delta) < Math.abs(current.delta) ? { delta, target } : current;
};
for (const source of sourcesX) for (const target of vertical) bestX = consider(source, target, bestX);
for (const source of sourcesY) for (const target of horizontal) bestY = consider(source, target, bestY);
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
const size = Number(ctx.gridSize);
for (const source of sourcesX) bestX = consider(source, Math.round(source / size) * size, bestX);
for (const source of sourcesY) bestY = consider(source, Math.round(source / size) * size, bestY);
}
return {
centerX: proposedCenter.x + (bestX?.delta || 0),
centerY: proposedCenter.y + (bestY?.delta || 0),
guides: [
...(bestX ? [{ vertical: true, x: bestX.target }] : []),
...(bestY ? [{ vertical: false, y: bestY.target }] : []),
],
};
}
/**
* CSS cursor name for each transform-tool handle.
*
* @param {'tl'|'tr'|'bl'|'br'|'rot'|string} id
* @param {'tl'|'t'|'tr'|'r'|'br'|'b'|'bl'|'l'|'rot'|string} id
* @param {number} rotation Current transform-frame rotation in degrees.
* @returns {string}
*/
export function cursorForHandle(id) {
switch (id) {
case 'tl': case 'br': return 'nwse-resize';
case 'tr': case 'bl': return 'nesw-resize';
case 'rot': return 'grab';
default: return 'default';
}
export function cursorForHandle(id, rotation = 0) {
if (id === 'rot') return 'grab';
const localAngles = {
r: 0, br: 45, b: 90, bl: 135,
l: 180, tl: 225, t: 270, tr: 315,
};
if (!(id in localAngles)) return 'default';
const angle = ((localAngles[id] + Number(rotation || 0)) % 180 + 180) % 180;
const direction = Math.round(angle / 45) % 4;
if (direction === 0) return 'ew-resize';
if (direction === 1) return 'nwse-resize';
if (direction === 2) return 'ns-resize';
return 'nesw-resize';
}
+107 -1
View File
@@ -19,11 +19,14 @@ export const state = {
// Drag-resize / rotate session state. While `transformActive` is
// false every field below should be considered stale.
transformActive: false,
transformTarget: null,
transformLayer: null,
transformOrigW: 0,
transformOrigH: 0,
transformSessionStartW: 0,
transformSessionStartH: 0,
// Which corner/edge handle the user is currently dragging. One of
// 'tl' | 'tr' | 'bl' | 'br' | 'rot' | null.
// 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | 'rot' | null.
transformHandle: null,
// Which handle is currently under the cursor (no drag). Drives the
// hover cursor lookup; lives next to `transformHandle` because both
@@ -32,7 +35,15 @@ export const state = {
// Snapshot of the layer canvas + offset at transform start so Cancel
// can restore exactly without re-fetching from the layer.
transformOrigCanvas: null,
transformOrigMasks: [],
transformOrigOffset: null,
transformSelectionCanvas: null,
transformLayers: [],
transformItems: [],
transformSelectionBounds: null,
transformBounds: null,
transformCenter: null,
transformStartCenter: null,
// In-progress dimensions / rotation / flips committed on Apply.
transformPendingW: 0,
transformPendingH: 0,
@@ -40,10 +51,16 @@ export const state = {
transformPendingFlipH: false,
transformPendingFlipV: false,
transformAspectLock: true,
// Keep raster transforms source-backed unless the user explicitly opts out
// in the Transform popup.
transformPreserveSource: true,
// Floating Transform popup element + drag-start offsets.
transformPopup: null,
transformStartX: 0,
transformStartY: 0,
transformStartRotation: 0,
transformStartFlipH: false,
transformStartFlipV: false,
transformStartOffX: 0,
transformStartOffY: 0,
// Transform overlay canvas — separate canvas positioned over the
@@ -60,6 +77,8 @@ export const state = {
// tolerance retunes can re-run the flood-fill without re-prompting.
wandMask: null,
wandLayerId: null,
wandMaskSpace: 'layer',
selectionSource: null,
wandTolerance: 24,
wandMaskVisible: true,
wandMode: 'replace',
@@ -81,12 +100,33 @@ export const state = {
brushOpacity: 100,
brushFlow: 100,
brushSoftness: 100,
smudgeStrength: 65,
eraserOpacity: 100,
eraserFlow: 100,
eraserSoftness: 100,
cloneOpacity: 100,
cloneFlow: 100,
cloneSoftness: 100,
brushSpacing: 15,
brushSmoothing: 25,
brushBlendMode: 'source-over',
gradientStart: '#e06c75',
gradientMid: '#808080',
gradientMidPosition: 50,
gradientMidEnabled: false,
gradientEnd: '#ffffff',
gradientEndAlpha: 100,
gradientOpacity: 100,
gradientType: 'linear-gradient',
gradientStops: [],
gradientActive: false,
// Flattened visual baseline for the non-destructive BEFORE compare view.
compareBaselineCanvas: null,
compareActive: false,
pressureSize: true,
pressureOpacity: false,
pressureFlow: true,
eyedropperSample: 'composite',
// Clone-stamp source point (set via Alt-click or double-tap). Null
// means no source picked yet — clicking with the clone tool no-ops
// until a source is set.
@@ -101,6 +141,12 @@ export const state = {
// original, not the in-progress stamp ring.
cloneSourceSnapshot: null,
cloneSourceLayerId: null,
// Where Clone / Healing reads pixels from. Keep the existing active-layer
// behavior as the default; composite sampling is useful for retouching a
// visible result while painting on a separate layer.
cloneSampleMode: 'active-layer',
cloneSourceOffsetX: 0,
cloneSourceOffsetY: 0,
// Mobile: double-tap detection for "set source" since Alt-click
// isn't an option without a keyboard.
cloneLastTapTime: 0,
@@ -114,6 +160,8 @@ export const state = {
maskCanvas: null,
maskCtx: null,
maskVisible: true,
// Show the active mask as a standalone black/white document preview.
maskInspectMode: false,
// Reused canvas for the union-of-masks tint pass (saves repeated
// allocation on every composite).
compositeMaskUnion: null,
@@ -157,6 +205,15 @@ export const state = {
// (mouse - startMouse) + startOffset rather than accumulating delta.
moveLayerOffsetX: 0,
moveLayerOffsetY: 0,
moveLayerOffsets: new Map(),
movePlacedSources: new Map(),
moveMaskTarget: null,
moveMaskOffsetX: 0,
moveMaskOffsetY: 0,
moveUnlinkedMaskOffsets: new Map(),
// Becomes true after the first real position change in a drag. A plain
// click must not create an empty undo step or clear the redo stack.
moveHistorySaved: false,
// Snap guides drawn during a move-tool drag (Ctrl held). Each entry
// is a vertical / horizontal line in canvas space.
activeSnapGuides: null,
@@ -178,6 +235,26 @@ export const state = {
lassoPoints: [],
lassoActive: false,
// ── Marquee selection ──
marqueeShape: 'rectangle',
marqueeConstraint: 'free',
marqueeRatioWidth: 1,
marqueeRatioHeight: 1,
marqueeFixedWidth: 100,
marqueeFixedHeight: 100,
marqueeActive: false,
marqueeStart: null,
marqueeRect: null,
selectionMoving: false,
selectionMoveStart: null,
selectionMoveOrigin: null,
quickMaskActive: false,
selectionOverlay: null,
selectionOverlayCtx: null,
savedSelections: [],
lastSelection: null,
nextSavedSelectionId: 1,
// In-editor copy/paste — separate from the OS clipboard so we can
// round-trip layer alpha and metadata losslessly.
internalClipboard: null,
@@ -189,10 +266,24 @@ export const state = {
// so the editor can reopen with fresh dimensions.
mainCanvas: null,
mainCtx: null,
// Transparent document composite. The checkerboard is drawn separately on
// mainCanvas so blend modes match flattened export instead of blending with UI.
documentCompositeCanvas: null,
// True only when documentCompositeCanvas matches the latest render
// generation. Autosave thumbnails may reuse it; async renders clear it
// before starting and set it again only after committing the frame.
documentRenderReady: false,
groupCompositeCanvas: null,
groupCompositeCanvases: new Map(),
clippingCompositeCanvas: null,
// ── Document + layers ──
layers: [],
activeLayerId: null,
selectedLayerIds: [],
selectionAnchorId: null,
layerGroups: [],
activeGroupId: null,
// Active tool ID — one of move/crop/transform/brush/eraser/clone/
// lasso/wand/inpaint/rembg/harmonize/sharpen/upscale/style.
tool: 'move',
@@ -201,6 +292,14 @@ export const state = {
zoom: 1,
panX: 0,
panY: 0,
spacePanActive: false,
navigationPanning: false,
rulersVisible: true,
gridVisible: false,
gridSize: 16,
snapEnabled: false,
snapToGrid: false,
guides: { vertical: [], horizontal: [] },
// Document dimensions in canvas pixels.
imgWidth: 0,
imgHeight: 0,
@@ -215,6 +314,8 @@ export const state = {
// that fire after the user closes the editor (don't draw onto a
// dead canvas, don't re-mount the spinner).
editorOpen: false,
// Changes whenever a new editor document owns the shared persistence lane.
editorSessionToken: 0,
// Document-level click-away handlers registered for the current
// session. Tracked so closeEditor can detach them all cleanly.
// Mutated in place (push / length = 0); the reference never changes.
@@ -237,6 +338,9 @@ export const state = {
adjPopupEl: null,
// rAF-throttled live preview while sliders are dragged in adj popups.
adjRafPending: false,
// Layer whose staged adjustment owns the queued preview frame. Clearing
// this invalidates a frame when the popup closes or changes layers.
adjPreviewLayer: null,
historyPanelEl: null,
// Custom brush-cursor overlay element (circle following the mouse).
cursorEl: null,
@@ -254,4 +358,6 @@ export const state = {
// True when an edit happened during an in-flight save — triggers a
// follow-up persist after the current one finishes.
persistDirty: false,
// Last surfaced autosave failure; avoids repeating the same toast per edit.
persistErrorMessage: null,
};
+263 -142
View File
@@ -1,162 +1,283 @@
/**
* Stroke pipeline — paints one segment (last-position → current
* position) onto the active layer (or its active mask sub-layer).
*
* `strokeTo` dispatches by tool:
* - clone → cloneStrokeTo (custom stamp-based paint loop)
* - brush → source-over with opacity × flow + softness blur
* - eraser → destination-out with opacity × flow + softness blur
* - inpaint → source-over (paint) or destination-out (erase) with
* full alpha on the mask canvas
*
* If the active parent has an active mask sub-layer, brush / eraser /
* inpaint target the mask canvas instead of the layer's pixel canvas.
*
* @param {{
* activeLayer: () => object | null,
* getActiveMaskLayer: () => object | null,
* composite: () => void,
* }} deps
*/
/** Shared sampled renderer for brush, eraser, clone, masks, and retouch tools. */
import { state } from './state.js';
import { createBrushSampler, pressureValue } from './brush-engine.js';
import { isLayerTransparencyLocked } from './layer-groups.js';
const PAINT_TOOLS = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
function toolSettings(tool) {
if (tool === 'eraser') return { opacity: state.eraserOpacity, flow: state.eraserFlow, softness: state.eraserSoftness };
if (tool === 'clone' || tool === 'heal') return { opacity: state.cloneOpacity, flow: state.cloneFlow, softness: state.cloneSoftness };
if (tool === 'smudge') return { opacity: state.smudgeStrength, flow: state.brushFlow, softness: state.brushSoftness };
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
}
function transparentColor(color) {
if (color.startsWith('#')) {
const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
return `rgba(${rgb.join(',')},0)`;
}
const match = color.match(/rgba?\(([^)]+)\)/);
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
}
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
const hard = Math.max(0, Math.min(1, 1 - softness));
ctx.save();
ctx.globalCompositeOperation = operation;
ctx.globalAlpha = alpha;
if (softness <= 0.001) {
ctx.fillStyle = color;
} else {
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColor(color));
ctx.fillStyle = gradient;
}
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
function cloneStrokeTo(x, y, layer) {
let strokeTool = null;
let compositeFrame = null;
let smudgeBuffer = null;
// Pointer events can arrive much faster than the display refresh rate.
// Stamps are applied synchronously, so only the visible document composite
// needs coalescing while a stroke is in progress.
function scheduleComposite() {
if (compositeFrame !== null) return;
if (typeof requestAnimationFrame === 'function') {
compositeFrame = requestAnimationFrame(() => {
compositeFrame = null;
composite();
});
} else {
compositeFrame = setTimeout(() => {
compositeFrame = null;
composite();
}, 0);
}
}
function flushComposite() {
if (compositeFrame !== null) {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(compositeFrame);
else clearTimeout(compositeFrame);
compositeFrame = null;
}
composite();
}
function targetFor(tool, layer) {
const activeMask = state.quickMaskActive && state.wandMask
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
: getActiveMaskLayer();
const paintingMask = !!activeMask && ['brush', 'eraser', 'inpaint'].includes(tool);
const ctx = paintingMask ? activeMask.ctx : (tool === 'inpaint' ? state.maskCtx : layer.ctx);
const layerOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const documentMask = paintingMask && activeMask.mode !== 'layer';
const maskOff = paintingMask && activeMask.mode === 'layer' ? (activeMask.offset || { x: 0, y: 0 }) : { x: 0, y: 0 };
return {
ctx,
paintingMask,
xOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.x + maskOff.x,
yOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.y + maskOff.y,
};
}
function cloneStamp(sample, layer, heal = false) {
if (!state.cloneSourceSnapshot) return;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const dx = x - state.cloneStrokeStartX;
const dy = y - state.cloneStrokeStartY;
const srcX = state.cloneSourceX + dx;
const srcY = state.cloneSourceY + dy;
const ctx = layer.ctx;
const radius = Math.max(1, state.brushSize / 2);
// Walk last → current in roughly half-brush steps so stamps
// overlap into a continuous brush trail.
const lastSrcX = state.cloneSourceX + (state.lastX - state.cloneStrokeStartX);
const lastSrcY = state.cloneSourceY + (state.lastY - state.cloneStrokeStartY);
const dist = Math.hypot(x - state.lastX, y - state.lastY);
const step = Math.max(1, radius * 0.5);
const steps = Math.max(1, Math.ceil(dist / step));
const stampSize = Math.max(2, Math.ceil(radius * 2));
const stampRadius = stampSize / 2;
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
const radius = Math.max(0.5, diameter / 2);
const settings = toolSettings(heal ? 'heal' : 'clone');
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
const sx = state.cloneSourceX - (state.cloneSourceOffsetX || 0) + (sample.x - state.cloneStrokeStartX);
const sy = state.cloneSourceY - (state.cloneSourceOffsetY || 0) + (sample.y - state.cloneStrokeStartY);
const size = Math.max(2, Math.ceil(radius * 2));
const stamp = document.createElement('canvas');
stamp.width = stampSize;
stamp.height = stampSize;
stamp.width = size;
stamp.height = size;
const stampCtx = stamp.getContext('2d');
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
const hardStop = stampRadius * (1 - softness);
stampCtx.drawImage(state.cloneSourceSnapshot, sx - radius, sy - radius, size, size, 0, 0, size, size);
stampCtx.globalCompositeOperation = 'destination-in';
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
stampCtx.fillStyle = mask;
stampCtx.fillRect(0, 0, size, size);
const ctx = layer.ctx;
ctx.save();
ctx.globalAlpha = (state.cloneOpacity / 100) * (state.cloneFlow / 100);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const px = state.lastX + (x - state.lastX) * t - off.x;
const py = state.lastY + (y - state.lastY) * t - off.y;
const sx = lastSrcX + (srcX - lastSrcX) * t;
const sy = lastSrcY + (srcY - lastSrcY) * t;
stampCtx.clearRect(0, 0, stampSize, stampSize);
stampCtx.globalCompositeOperation = 'source-over';
stampCtx.drawImage(
state.cloneSourceSnapshot,
sx - stampRadius, sy - stampRadius, stampSize, stampSize,
0, 0, stampSize, stampSize,
);
stampCtx.globalCompositeOperation = 'destination-in';
const mask = stampCtx.createRadialGradient(stampRadius, stampRadius, hardStop, stampRadius, stampRadius, stampRadius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
stampCtx.fillStyle = mask;
stampCtx.fillRect(0, 0, stampSize, stampSize);
ctx.drawImage(stamp, px - stampRadius, py - stampRadius);
}
ctx.globalAlpha = alpha;
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
if (heal) ctx.filter = `blur(${Math.max(0.4, radius * 0.08).toFixed(2)}px)`;
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
ctx.restore();
state.lastX = x;
state.lastY = y;
composite();
}
function strokeTo(x, y) {
function spotHealStamp(sample, layer) {
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
const radius = Math.max(0.5, diameter / 2);
const settings = toolSettings('heal');
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
const x = Math.round(sample.x - off.x);
const y = Math.round(sample.y - off.y);
const ring = Math.max(2, Math.ceil(radius * 2.2));
const left = Math.max(0, x - ring);
const top = Math.max(0, y - ring);
const right = Math.min(layer.canvas.width - 1, x + ring);
const bottom = Math.min(layer.canvas.height - 1, y + ring);
if (right < left || bottom < top) return;
const pixels = layer.ctx.getImageData(left, top, right - left + 1, bottom - top + 1).data;
let red = 0, green = 0, blue = 0, alphaSum = 0, weightSum = 0;
const exclusion = Math.max(1, radius * 0.72);
for (let py = top; py <= bottom; py += 1) {
for (let px = left; px <= right; px += 1) {
const distance = Math.hypot(px - x, py - y);
if (distance < exclusion || distance > ring) continue;
const index = ((py - top) * (right - left + 1) + (px - left)) * 4;
const weight = 1 / Math.max(1, distance);
red += pixels[index] * weight;
green += pixels[index + 1] * weight;
blue += pixels[index + 2] * weight;
alphaSum += pixels[index + 3] * weight;
weightSum += weight;
}
}
if (!weightSum) return;
const color = `rgba(${Math.round(red / weightSum)},${Math.round(green / weightSum)},${Math.round(blue / weightSum)},${Math.max(0, Math.min(1, alphaSum / weightSum / 255))})`;
circleStamp(layer.ctx, x, y, radius, Math.max(0, Math.min(1, settings.softness / 300)), color, alpha, 'source-over');
}
function smudgeStamp(sample, layer) {
const previousX = Number.isFinite(state.lastX) ? state.lastX : sample.x;
const previousY = Number.isFinite(state.lastY) ? state.lastY : sample.y;
const distance = Math.hypot(sample.x - previousX, sample.y - previousY);
if (distance < 0.25) return;
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
const radius = Math.max(0.5, diameter / 2);
const settings = toolSettings('smudge');
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
const size = Math.max(2, Math.ceil(radius * 2));
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
if (!smudgeBuffer || smudgeBuffer.width !== size || smudgeBuffer.height !== size) {
smudgeBuffer = document.createElement('canvas');
smudgeBuffer.width = size;
smudgeBuffer.height = size;
smudgeBuffer.getContext('2d').drawImage(
layer.canvas,
previousX - off.x - radius, previousY - off.y - radius, size, size,
0, 0, size, size,
);
return;
}
const stamp = document.createElement('canvas');
stamp.width = size;
stamp.height = size;
const stampCtx = stamp.getContext('2d');
stampCtx.drawImage(smudgeBuffer, 0, 0);
stampCtx.globalCompositeOperation = 'destination-in';
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
stampCtx.fillStyle = mask;
stampCtx.fillRect(0, 0, size, size);
const ctx = layer.ctx;
ctx.save();
ctx.globalAlpha = alpha;
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
ctx.restore();
// Capture the newly mixed patch so the next sample carries the smear
// forward instead of repeatedly sampling the untouched canvas.
smudgeBuffer.getContext('2d').clearRect(0, 0, size, size);
smudgeBuffer.getContext('2d').drawImage(
layer.canvas,
sample.x - off.x - radius, sample.y - off.y - radius, size, size,
0, 0, size, size,
);
}
function renderSample(sample) {
const tool = strokeTool || state.tool;
if (!PAINT_TOOLS.has(tool)) return;
const layer = activeLayer();
if (!layer) return;
// Clone uses a stamp-based paint loop, not the line-stroke
// pipeline below.
if (state.tool === 'clone') return cloneStrokeTo(x, y, layer);
// If the active parent has an active mask sub-layer, brush /
// eraser / inpaint paint the mask canvas instead of the layer's
// pixel canvas. Brush adds to the mask, Eraser carves it away,
// Inpaint still works (its mask plumbing was already pointed at
// the same canvas).
const activeMask = getActiveMaskLayer();
const paintingMask = !!activeMask &&
(state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint');
const ctx = paintingMask
? activeMask.ctx
: (state.tool === 'inpaint' ? state.maskCtx : layer.ctx);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.save();
ctx.lineWidth = state.brushSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (state.tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
// Effective alpha = opacity × flow. Opacity = max strength a
// stroke can reach; flow = how much erases per pass.
ctx.globalAlpha = (state.eraserOpacity / 100) * (state.eraserFlow / 100);
ctx.strokeStyle = 'rgba(0,0,0,1)';
if (state.eraserSoftness > 0) {
const blurPx = (state.eraserSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
} else if (state.tool === 'brush') {
// Brush — state.color onto the layer (or white onto an active
// mask sub-layer). Mask painting forces full alpha so masks
// stay a clean binary by default (a sub-100% brush would
// silently paint partial-strength mask pixels).
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = paintingMask ? 'rgba(255,255,255,1)' : state.color;
if (paintingMask) {
ctx.globalAlpha = 1;
} else {
ctx.globalAlpha = (state.brushOpacity / 100) * (state.brushFlow / 100);
if (state.brushSoftness > 0) {
const blurPx = (state.brushSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
}
} else if (state.tool === 'inpaint') {
if (state.inpaintEraseStroke) {
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
} else {
ctx.globalCompositeOperation = 'source-over';
// Diffusion server expects white = inpaint area. The red
// overlay is rendered separately in composite() for the user.
ctx.strokeStyle = 'rgba(255,255,255,1)';
}
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = state.color;
if (tool === 'clone') return cloneStamp(sample, layer);
if (tool === 'heal') {
// A healing stroke without a picked source uses the convenient local
// spot correction. With a source snapshot, blend sampled texture into
// the destination instead of silently falling back to ring averaging.
return state.cloneSourceSnapshot
? cloneStamp(sample, layer, true)
: spotHealStamp(sample, layer);
}
if (tool === 'smudge') return smudgeStamp(sample, layer);
const target = targetFor(tool, layer);
if (!target.ctx) return;
const settings = toolSettings(tool);
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
const radius = Math.max(0.5, diameter / 2);
const opacity = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity);
const flow = pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
let operation = 'source-over';
let color = state.color;
let alpha = opacity * flow;
let softness = Math.max(0, Math.min(1, settings.softness / 300));
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
else if (tool === 'inpaint') {
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
color = 'rgba(255,255,255,1)';
alpha = 1;
softness = 0;
} else if (tool === 'dodge') { operation = 'screen'; color = 'rgba(255,255,255,1)'; }
else if (tool === 'burn') { operation = 'multiply'; color = 'rgba(0,0,0,1)'; }
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
circleStamp(target.ctx, sample.x - target.xOffset, sample.y - target.yOffset, radius, softness, color, alpha, operation);
}
// Mask canvases are always full-image (no per-layer offset), so
// painting onto a mask uses canvas-coord origin too — same as
// inpaint.
const onMaskOrInpaint = paintingMask || state.tool === 'inpaint';
const drawX = onMaskOrInpaint ? 0 : off.x;
const drawY = onMaskOrInpaint ? 0 : off.y;
const sampler = createBrushSampler({
getDiameter: () => state.brushSize,
getSpacing: () => state.brushSpacing / 100,
getSmoothing: () => state.brushSmoothing / 100,
emit: renderSample,
});
ctx.beginPath();
ctx.moveTo(state.lastX - drawX, state.lastY - drawY);
ctx.lineTo(x - drawX, y - drawY);
ctx.stroke();
ctx.restore();
state.lastX = x;
state.lastY = y;
function beginStroke(sample, tool = state.tool) {
strokeTool = tool;
smudgeBuffer = null;
sampler.begin(sample);
state.lastX = sample.x;
state.lastY = sample.y;
composite();
}
return { strokeTo, cloneStrokeTo };
function strokeTo(sample) {
sampler.add(sample);
state.lastX = sample.x;
state.lastY = sample.y;
scheduleComposite();
}
function endStroke(sample) {
sampler.end(sample);
strokeTool = null;
smudgeBuffer = null;
flushComposite();
}
return { beginStroke, strokeTo, endStroke };
}
+74 -1
View File
@@ -19,6 +19,12 @@
* + their labels + preview swatches.
*/
import { state } from './state.js';
import {
applyBrushPreset,
captureBrushPreset,
loadBrushPresets,
saveCustomBrushPresets,
} from './brush-presets.js';
/** Wire the three sliders for one stroke tool. */
function wireToolSliders(prefix, fields) {
@@ -57,8 +63,75 @@ function wireToolSliders(prefix, fields) {
});
}
export function wireStrokeToolSliders() {
export function wireStrokeToolSliders({ onPresetApplied } = {}) {
wireToolSliders('eraser', { opacity: 'eraserOpacity', flow: 'eraserFlow', softness: 'eraserSoftness' });
wireToolSliders('brush', { opacity: 'brushOpacity', flow: 'brushFlow', softness: 'brushSoftness' });
wireToolSliders('clone', { opacity: 'cloneOpacity', flow: 'cloneFlow', softness: 'cloneSoftness' });
const smudgeStrength = document.getElementById('ge-smudge-strength');
smudgeStrength?.addEventListener('input', (e) => {
state.smudgeStrength = parseInt(e.target.value, 10);
document.getElementById('ge-smudge-strength-label').textContent = `${state.smudgeStrength}%`;
const preview = document.getElementById('ge-smudge-preview-strength');
if (preview) preview.style.opacity = (state.smudgeStrength / 100).toFixed(2);
});
const spacing = document.getElementById('ge-brush-spacing');
const smoothing = document.getElementById('ge-brush-smoothing');
const blend = document.getElementById('ge-brush-blend');
spacing?.addEventListener('input', (e) => {
state.brushSpacing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
});
smoothing?.addEventListener('input', (e) => {
state.brushSmoothing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
});
blend?.addEventListener('change', (e) => { state.brushBlendMode = e.target.value; });
document.getElementById('ge-pressure-size')?.addEventListener('change', e => { state.pressureSize = e.target.checked; });
document.getElementById('ge-pressure-opacity')?.addEventListener('change', e => { state.pressureOpacity = e.target.checked; });
document.getElementById('ge-pressure-flow')?.addEventListener('change', e => { state.pressureFlow = e.target.checked; });
document.getElementById('ge-eyedropper-sample')?.addEventListener('change', e => { state.eyedropperSample = e.target.value; });
let presets = loadBrushPresets();
const presetSelect = document.getElementById('ge-brush-preset');
const renderPresets = (selectedId = '') => {
if (!presetSelect) return;
presetSelect.innerHTML = '<option value="">Brush presets</option>' + presets.map(preset =>
`<option value="${preset.id}">${preset.name}</option>`).join('');
presetSelect.value = selectedId;
};
const syncControls = () => {
if (spacing) spacing.value = String(state.brushSpacing);
if (smoothing) smoothing.value = String(state.brushSmoothing);
if (blend) blend.value = state.brushBlendMode;
if (smudgeStrength) smudgeStrength.value = String(state.smudgeStrength);
const smudgeLabel = document.getElementById('ge-smudge-strength-label');
if (smudgeLabel) smudgeLabel.textContent = `${state.smudgeStrength}%`;
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
onPresetApplied?.();
};
renderPresets();
presetSelect?.addEventListener('change', () => {
const preset = presets.find(item => item.id === presetSelect.value);
if (!preset) return;
applyBrushPreset(state, preset);
syncControls();
});
document.getElementById('ge-brush-preset-save')?.addEventListener('click', () => {
const name = window.prompt('Brush preset name');
if (!name?.trim()) return;
const preset = captureBrushPreset(state, name);
presets.push(preset);
saveCustomBrushPresets(presets);
renderPresets(preset.id);
});
document.getElementById('ge-brush-preset-delete')?.addEventListener('click', () => {
const preset = presets.find(item => item.id === presetSelect?.value);
if (!preset || preset.builtIn) return;
presets = presets.filter(item => item.id !== preset.id);
saveCustomBrushPresets(presets);
renderPresets();
});
}
+93
View File
@@ -0,0 +1,93 @@
/** Direct on-canvas editor for retained text layers. */
function textFont(text) {
const family = text.fontFamily.includes(',') ? text.fontFamily : `"${text.fontFamily.replace(/["\\]/g, '')}", Arial, sans-serif`;
return `${text.fontStyle} ${text.fontWeight} ${text.fontSize}px/${text.lineHeight} ${family}`;
}
export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
let input = null;
let layer = null;
let resizeObserver = null;
let frame = 0;
function position() {
if (!input || !layer || !state.mainCanvas?.isConnected) return;
const canvasRect = state.mainCanvas.getBoundingClientRect();
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const scaleX = canvasRect.width / Math.max(1, state.mainCanvas.width);
const scaleY = canvasRect.height / Math.max(1, state.mainCanvas.height);
const text = layer.text;
input.style.left = `${canvasRect.left + offset.x * scaleX}px`;
input.style.top = `${canvasRect.top + offset.y * scaleY}px`;
input.style.minWidth = `${Math.max(80, text.frameWidth * scaleX)}px`;
input.style.width = `${Math.max(80, text.frameWidth * scaleX)}px`;
input.style.minHeight = `${Math.max(36, layer.canvas.height * scaleY)}px`;
input.style.font = textFont({ ...text, fontSize: text.fontSize * scaleY });
input.style.letterSpacing = `${text.letterSpacing * scaleX}px`;
input.style.textAlign = text.align;
input.style.color = text.color;
}
function loop() {
position();
if (input) frame = requestAnimationFrame(loop);
}
function close(commit = true) {
if (!input) return;
const value = input.value;
resizeObserver?.disconnect();
cancelAnimationFrame(frame);
input.remove();
input = null;
const target = layer;
layer = null;
if (commit) onCommit?.(target, value); else onCancel?.(target);
}
function open(target, { selectAll = false } = {}) {
close(true);
layer = target;
input = document.createElement('textarea');
input.className = 'ge-direct-text-editor';
input.value = target.text.content;
input.setAttribute('aria-label', 'Edit text on canvas');
input.spellcheck = true;
input.addEventListener('input', () => onInput?.(target, input.value, input));
input.addEventListener('keydown', event => {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
close(false);
} else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
close(true);
}
});
let ready = false;
input.addEventListener('blur', () => setTimeout(() => {
if (ready && input && document.activeElement !== input) close(true);
}, 0));
document.body.appendChild(input);
resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => {
if (!input || !layer) return;
const canvasRect = state.mainCanvas.getBoundingClientRect();
const scale = canvasRect.width / Math.max(1, state.mainCanvas.width);
const width = Math.max(1, input.getBoundingClientRect().width / Math.max(0.01, scale));
onInput?.(layer, input.value, input, width);
}) : null;
resizeObserver?.observe(input);
position();
frame = requestAnimationFrame(() => {
if (!input) return;
input.focus({ preventScroll: true });
if (selectAll) input.select();
ready = true;
frame = requestAnimationFrame(loop);
});
return input;
}
return { open, close, position, isOpen: () => !!input, activeLayer: () => layer };
}
+203
View File
@@ -0,0 +1,203 @@
/** Retained text-layer model and raster-cache renderer. */
export const DEFAULT_TEXT_DATA = Object.freeze({
content: 'Text',
fontFamily: 'Arial',
fontSize: 48,
fontWeight: '400',
fontStyle: 'normal',
align: 'left',
color: '#ffffff',
strokeColor: '#000000',
strokeWidth: 0,
lineHeight: 1.2,
letterSpacing: 0,
frameWidth: 320,
frameHeight: 0,
autoWidth: true,
verticalAlign: 'top',
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
});
function finite(value, fallback, min = -Infinity, max = Infinity) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
export function normalizeTextData(value = {}) {
const transform = value.transform || {};
return {
content: String(value.content ?? DEFAULT_TEXT_DATA.content),
fontFamily: String(value.fontFamily || DEFAULT_TEXT_DATA.fontFamily),
fontSize: finite(value.fontSize, DEFAULT_TEXT_DATA.fontSize, 1, 2000),
fontWeight: String(value.fontWeight || DEFAULT_TEXT_DATA.fontWeight),
fontStyle: value.fontStyle === 'italic' ? 'italic' : 'normal',
align: ['left', 'center', 'right'].includes(value.align) ? value.align : 'left',
color: String(value.color || DEFAULT_TEXT_DATA.color),
strokeColor: String(value.strokeColor || DEFAULT_TEXT_DATA.strokeColor),
strokeWidth: finite(value.strokeWidth, DEFAULT_TEXT_DATA.strokeWidth, 0, 100),
lineHeight: finite(value.lineHeight, DEFAULT_TEXT_DATA.lineHeight, 0.5, 5),
letterSpacing: finite(value.letterSpacing, DEFAULT_TEXT_DATA.letterSpacing, -100, 500),
frameWidth: finite(value.frameWidth, DEFAULT_TEXT_DATA.frameWidth, 1, 10000),
frameHeight: finite(value.frameHeight, DEFAULT_TEXT_DATA.frameHeight, 0, 10000),
autoWidth: value.autoWidth !== false,
verticalAlign: ['top', 'middle', 'bottom'].includes(value.verticalAlign) ? value.verticalAlign : 'top',
transform: {
scaleX: finite(transform.scaleX, 1, 0.01, 100),
scaleY: finite(transform.scaleY, 1, 0.01, 100),
rotation: finite(transform.rotation, 0, -36000, 36000),
flipH: !!transform.flipH,
flipV: !!transform.flipV,
},
};
}
function fontString(text) {
const family = text.fontFamily.includes(',')
? text.fontFamily
: `"${text.fontFamily.replace(/["\\]/g, '')}", Arial, sans-serif`;
return `${text.fontStyle} ${text.fontWeight} ${text.fontSize}px ${family}`;
}
function measureSpacedText(ctx, value, spacing) {
const chars = [...String(value || '')];
if (!chars.length) return 0;
return chars.reduce((width, char) => width + ctx.measureText(char).width, 0) + spacing * Math.max(0, chars.length - 1);
}
function drawSpacedText(ctx, value, x, y, spacing, stroke = false) {
const chars = [...String(value || '')];
if (!chars.length) return;
const width = measureSpacedText(ctx, value, spacing);
let cursor = ctx.textAlign === 'center' ? x - width / 2 : ctx.textAlign === 'right' ? x - width : x;
const draw = stroke ? ctx.strokeText.bind(ctx) : ctx.fillText.bind(ctx);
for (const char of chars) {
draw(char, cursor, y);
cursor += ctx.measureText(char).width + spacing;
}
}
function wrapText(ctx, content, width, spacing) {
const result = [];
for (const paragraph of String(content).split('\n')) {
if (!width) {
result.push(paragraph);
continue;
}
const words = paragraph.split(/(\s+)/).filter(Boolean);
if (!words.length) {
result.push('');
continue;
}
let line = '';
for (const word of words) {
const candidate = line + word;
if (line && measureSpacedText(ctx, candidate, spacing) > width) {
result.push(line.trimEnd());
line = word.trimStart();
} else {
line = candidate;
}
}
result.push(line);
}
return result.length ? result : [''];
}
function baseTextCanvas(text) {
const measure = document.createElement('canvas');
const measureCtx = measure.getContext('2d');
measureCtx.font = fontString(text);
const padding = Math.ceil(Math.max(4, text.strokeWidth + 3));
const requestedWidth = text.autoWidth ? 0 : Math.max(1, text.frameWidth);
const lines = wrapText(measureCtx, text.content, requestedWidth, text.letterSpacing);
const measuredWidth = Math.max(1, ...lines.map(line => measureSpacedText(measureCtx, line || ' ', text.letterSpacing)));
const contentWidth = text.autoWidth ? measuredWidth : requestedWidth;
const lineHeightPx = text.fontSize * text.lineHeight;
const contentHeight = Math.max(1, lines.length * lineHeightPx);
const frameHeight = text.frameHeight > 0 ? Math.max(contentHeight, text.frameHeight) : contentHeight;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.ceil(contentWidth + padding * 2));
canvas.height = Math.max(1, Math.ceil(frameHeight + padding * 2));
const ctx = canvas.getContext('2d');
ctx.font = fontString(text);
ctx.textBaseline = 'top';
ctx.textAlign = text.align;
ctx.fillStyle = text.color;
ctx.strokeStyle = text.strokeColor;
ctx.lineWidth = text.strokeWidth * 2;
ctx.lineJoin = 'round';
const x = text.align === 'center' ? canvas.width / 2 : text.align === 'right' ? canvas.width - padding : padding;
const verticalOffset = text.verticalAlign === 'middle'
? (frameHeight - contentHeight) / 2
: text.verticalAlign === 'bottom' ? frameHeight - contentHeight : 0;
lines.forEach((line, index) => {
const y = padding + verticalOffset + index * lineHeightPx;
if (text.strokeWidth > 0) drawSpacedText(ctx, line, x, y, text.letterSpacing, true);
drawSpacedText(ctx, line, x, y, text.letterSpacing, false);
});
return canvas;
}
export function renderTextLayer(layer) {
if (!layer?.canvas || !layer?.ctx) return null;
layer.kind = 'text';
layer.text = normalizeTextData(layer.text);
const base = baseTextCanvas(layer.text);
const transform = layer.text.transform;
const scaledWidth = Math.max(1, base.width * transform.scaleX);
const scaledHeight = Math.max(1, base.height * transform.scaleY);
const radians = (transform.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, Math.ceil(scaledWidth * cos + scaledHeight * sin));
const height = Math.max(1, Math.ceil(scaledWidth * sin + scaledHeight * cos));
const rendered = document.createElement('canvas');
rendered.width = width;
rendered.height = height;
const ctx = rendered.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(width / 2, height / 2);
ctx.rotate(radians);
ctx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
ctx.drawImage(base, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
layer.canvas.width = width;
layer.canvas.height = height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.clearRect(0, 0, width, height);
layer.ctx.drawImage(rendered, 0, 0);
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
return layer.canvas;
}
/** Mark a retained text cache as ordinary pixels after a destructive edit. */
export function rasterizeTextLayer(layer) {
if (!layer || layer.kind !== 'text') return false;
layer.kind = 'raster';
layer.text = null;
return true;
}
export function scaleTextLayer(layer, scaleX, scaleY) {
layer.text = normalizeTextData(layer.text);
layer.text.transform.scaleX *= scaleX;
layer.text.transform.scaleY *= scaleY;
return renderTextLayer(layer);
}
export function rotateTextLayer(layer, degrees) {
layer.text = normalizeTextData(layer.text);
layer.text.transform.rotation += degrees;
return renderTextLayer(layer);
}
export function flipTextLayer(layer, axis) {
layer.text = normalizeTextData(layer.text);
if (axis === 'h') layer.text.transform.flipH = !layer.text.transform.flipH;
else layer.text.transform.flipV = !layer.text.transform.flipV;
return renderTextLayer(layer);
}
+18
View File
@@ -0,0 +1,18 @@
/* Encode a completed document surface without blocking the editor thread. */
self.onmessage = async event => {
const { bitmap, maxDim = 160, quality = 0.6 } = event.data || {};
try {
if (!bitmap?.width || !bitmap?.height) throw new Error('Thumbnail source is empty');
const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(width, height);
canvas.getContext('2d').drawImage(bitmap, 0, 0, width, height);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
self.postMessage({ blob });
} catch (error) {
self.postMessage({ error: String(error?.message || error) });
} finally {
bitmap?.close?.();
}
};
+7
View File
@@ -0,0 +1,7 @@
// Shared by the palette, dispatch map and shortcut reference.
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', pen: 'P',
});
+44 -7
View File
@@ -13,12 +13,14 @@
* saveState: (label?: string) => void,
* strokeTo: (x: number, y: number) => void,
* showToast: (msg: string) => void,
* onSourceChanged?: () => void,
* }} deps
*/
import { state } from '../state.js';
import { isLayerPixelLocked } from '../layer-groups.js';
import { canvasCoords } from '../canvas-coords.js';
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
export function createCloneTool({ activeLayer, saveState, beginStroke, showToast, onSourceChanged, getSampleCanvas }) {
return {
begin(e) {
const layer = activeLayer();
@@ -49,33 +51,68 @@ export function createCloneTool({ activeLayer, saveState, strokeTo, showToast })
state.cloneSourceX = coords.x;
state.cloneSourceY = coords.y;
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
const sampledCanvas = getSampleCanvas?.({ layer, mode: state.cloneSampleMode });
const sourceLayer = layer && (
state.cloneSampleMode === 'active-layer' || !sampledCanvas
) ? layer : null;
const sourceOffset = sourceLayer ? (state.layerOffsets.get(sourceLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
state.cloneSourceOffsetX = sourceOffset.x;
state.cloneSourceOffsetY = sourceOffset.y;
state.cloneSourceSnapshot = null; // captured at first stroke
onSourceChanged?.();
showToast('Clone source set');
return;
}
// Healing defaults to source-free spot correction. Once a source is
// chosen, the same tool switches to sampled healing in the pipeline.
if (state.tool === 'heal' && (state.cloneSourceX === null || state.cloneSourceY === null)) {
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before healing');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before healing its pixels');
return;
}
saveState('Healing stroke');
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
return;
}
if (state.cloneSourceX === null || state.cloneSourceY === null) {
showToast(isTouchEvt
? 'Double-tap first to set a clone source'
: 'Alt-click first to set a clone source');
return;
}
if (!layer || layer.locked) return;
saveState('Clone stroke');
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before cloning');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before cloning into it');
return;
}
const toolName = state.tool === 'heal' ? 'Healing stroke' : 'Clone stroke';
saveState(toolName);
// Snapshot the source layer's pixels at stroke-start so the
// brush samples clean source pixels even after it has painted
// over them. Otherwise we'd cascade-clone the same ring.
const srcLayer = state.layers.find(l => l.id === state.cloneSourceLayerId) || layer;
const sourceCanvas = getSampleCanvas?.({ layer: srcLayer, mode: state.cloneSampleMode }) || srcLayer.canvas;
const snap = document.createElement('canvas');
snap.width = srcLayer.canvas.width;
snap.height = srcLayer.canvas.height;
snap.getContext('2d').drawImage(srcLayer.canvas, 0, 0);
snap.width = sourceCanvas.width;
snap.height = sourceCanvas.height;
snap.getContext('2d').drawImage(sourceCanvas, 0, 0);
state.cloneSourceSnapshot = snap;
state.cloneStrokeStartX = coords.x;
state.cloneStrokeStartY = coords.y;
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
strokeTo(coords.x, coords.y);
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
},
};
}
+45 -2
View File
@@ -17,11 +17,30 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { drawCheckerboard } from '../checkerboard.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createCropTool({ composite, showCropApply }) {
export function createCropTool({ composite, showCropApply, snapFrame }) {
const gesture = createDirectManipulationSession({
name: 'crop',
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'crop',
onCancel: (data, reason) => {
state.cropping = false;
state.cropMoving = false;
state.cropStart = null;
state.cropEnd = null;
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
composite();
if (state.cropRect && reason !== 'tool-switch') showCropApply();
},
});
return {
begin(e) {
const coords = canvasCoords(e, state.mainCanvas);
const previousRect = state.cropRect ? { ...state.cropRect } : null;
// Click inside an existing crop rect → switch to move-mode so
// the user can reposition without redrawing.
if (state.cropRect &&
@@ -29,6 +48,7 @@ export function createCropTool({ composite, showCropApply }) {
coords.y >= state.cropRect.y && coords.y <= state.cropRect.y + state.cropRect.h) {
state.cropMoving = true;
state.cropMoveStart = { x: coords.x, y: coords.y, rx: state.cropRect.x, ry: state.cropRect.y };
gesture.begin(e, { mode: 'move', previousRect }, { captureTarget: e.currentTarget });
return;
}
state.cropping = true;
@@ -39,9 +59,11 @@ export function createCropTool({ composite, showCropApply }) {
// Tear down the size panel while the user is drawing a new rect.
const old = state.container?.querySelector('.ge-crop-apply');
if (old) old.remove();
gesture.begin(e, { mode: 'draw', previousRect }, { captureTarget: e.currentTarget });
},
drag(e) {
if (!gesture.update(e)) return;
// Move-mode: drag the existing rect around the canvas.
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
e.preventDefault();
@@ -50,6 +72,22 @@ export function createCropTool({ composite, showCropApply }) {
const dy = c.y - state.cropMoveStart.y;
let nx = state.cropMoveStart.rx + dx;
let ny = state.cropMoveStart.ry + dy;
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping && snapFrame) {
const snapped = snapFrame({
centerX: state.cropMoveStart.rx + state.cropRect.w / 2,
centerY: state.cropMoveStart.ry + state.cropRect.h / 2,
width: state.cropRect.w,
height: state.cropRect.h,
rotation: 0,
}, { x: nx + state.cropRect.w / 2, y: ny + state.cropRect.h / 2 });
nx = snapped.centerX - state.cropRect.w / 2;
ny = snapped.centerY - state.cropRect.h / 2;
state.activeSnapGuides = snapped.guides || null;
} else {
state.activeSnapGuides = null;
}
// Clamp to canvas bounds so the rect stays fully visible.
nx = Math.max(0, Math.min(nx, state.mainCanvas.width - state.cropRect.w));
ny = Math.max(0, Math.min(ny, state.mainCanvas.height - state.cropRect.h));
@@ -119,7 +157,9 @@ export function createCropTool({ composite, showCropApply }) {
state.cropRect = { x, y, w, h };
},
end() {
end(e) {
if (!gesture.commit(e)) return;
state.activeSnapGuides = null;
// Move-mode wrap-up: refresh the floating panel so Apply follows
// the rect to its new spot.
if (state.cropMoving) {
@@ -133,5 +173,8 @@ export function createCropTool({ composite, showCropApply }) {
showCropApply();
}
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+51
View File
@@ -0,0 +1,51 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
function hexByte(value) {
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
}
export function createEyedropperTool({ activeLayer, composite, syncColor, syncPreview, showToast }) {
function sample(e, refreshComposite = false) {
const point = canvasCoords(e, state.mainCanvas);
let canvas = state.mainCanvas;
let x = point.x;
let y = point.y;
if (state.eyedropperSample === 'layer') {
const layer = activeLayer();
if (!layer?.canvas) return null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
canvas = layer.canvas;
x -= off.x;
y -= off.y;
} else if (refreshComposite) {
composite();
}
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
const pixel = canvas.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const rgb = [pixel[0], pixel[1], pixel[2]];
if (!pixel[3]) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(pixel[0])}${hexByte(pixel[1])}${hexByte(pixel[2])}`, rgb, canvas, x, y };
}
return {
preview(e) {
const result = sample(e);
syncPreview?.(result?.color || null, result);
return result?.color || null;
},
clearPreview() {
syncPreview?.(null, null);
},
pick(e) {
const result = sample(e, true);
const color = result?.color;
if (!color) {
showToast?.('That pixel is transparent');
return;
}
state.color = color;
syncColor?.(state.color);
syncPreview?.(state.color, result);
},
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Local two-color gradient tool.
*
* The drag becomes a retained effect on completion, so previews never mutate
* source pixels or compound across pointer-move events.
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { isLayerPixelLocked } from '../layer-groups.js';
export function createGradientTool({ activeLayer, saveState, composite, schedulePersist, renderLayerPanel, getSettings }) {
let start = null;
let lastPoint = null;
let activeLayerId = null;
let historySaved = false;
function settings() {
const values = getSettings?.() || {};
return {
start: values.start || state.gradientStart || state.color,
mid: values.mid || state.gradientMid || '#808080',
midPosition: values.midPosition ?? state.gradientMidPosition ?? 50,
midEnabled: values.midEnabled ?? state.gradientMidEnabled ?? false,
extraStops: Array.isArray(values.extraStops) ? values.extraStops : (state.gradientStops || []),
end: values.end || state.gradientEnd || '#ffffff',
endAlpha: values.endAlpha ?? state.gradientEndAlpha ?? 100,
opacity: values.opacity ?? state.gradientOpacity ?? 100,
type: values.type || state.gradientType || 'linear-gradient',
};
}
function preview(point) {
const layer = activeLayer();
if (!start || !layer || layer.id !== activeLayerId) return false;
const dx = point.x - start.x;
const dy = point.y - start.y;
if (Math.hypot(dx, dy) < 0.5) return false;
const values = settings();
const stops = [
{ position: 0, color: values.start, alpha: 1 },
...(values.midEnabled ? [{ position: Number(values.midPosition), color: values.mid, alpha: 1 }] : []),
...values.extraStops.slice(0, 10).map(stop => ({
position: Math.max(1, Math.min(99, Number(stop.position) || 50)),
color: stop.color || '#808080',
alpha: 1,
})),
{ position: 100, color: values.end, alpha: Math.max(0, Math.min(100, Number(values.endAlpha))) / 100 },
].sort((a, b) => a.position - b.position);
layer._effectPreview = {
id: 'gradient-preview', type: values.type, name: values.type === 'radial-gradient' ? 'Radial Gradient' : 'Gradient', visible: true, opacity: 1,
params: { x1: start.x, y1: start.y, x2: point.x, y2: point.y, stops, opacity: Math.max(0, Math.min(100, Number(values.opacity))) / 100 },
};
composite();
return true;
}
return {
begin(e) {
const layer = activeLayer();
if (!layer) return false;
if (isLayerPixelLocked(state, layer)) return false;
if (layer.kind === 'placed') return false;
activeLayerId = layer.id;
start = canvasCoords(e, state.mainCanvas);
lastPoint = { ...start };
state.gradientActive = true;
historySaved = false;
return true;
},
drag(e) {
if (!state.gradientActive) return false;
lastPoint = canvasCoords(e, state.mainCanvas);
preview(lastPoint);
return true;
},
end(e) {
if (!state.gradientActive) return false;
const point = e && (Number.isFinite(e.clientX) || (e.touches && e.touches.length))
? canvasCoords(e, state.mainCanvas)
: lastPoint;
const changed = point ? preview(point) : false;
const layer = activeLayer();
state.gradientActive = false;
if (layer?._effectPreview && changed) {
if (!historySaved) {
saveState('Gradient');
historySaved = true;
}
layer.effects = Array.isArray(layer.effects) ? layer.effects : [];
layer.effects.push(layer._effectPreview);
}
if (layer) layer._effectPreview = null;
start = null;
lastPoint = null;
activeLayerId = null;
historySaved = false;
if (changed) {
renderLayerPanel?.();
schedulePersist?.();
}
return true;
},
cancel() {
if (!state.gradientActive) return false;
const layer = activeLayer();
if (layer) layer._effectPreview = null;
state.gradientActive = false;
start = null;
lastPoint = null;
activeLayerId = null;
historySaved = false;
composite();
return true;
},
};
}
+33 -3
View File
@@ -13,11 +13,18 @@
* }} deps
*/
import { state } from '../state.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
import { canvasCoords } from '../canvas-coords.js';
import { buildLassoMask } from './lasso-mask.js';
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
export function createLassoTool({
activeLayer, saveState, commitSelectionMask,
composite, drawLassoOverlay, syncToolClearIndicators,
}) {
let pendingMode = 'replace';
return {
begin(e) {
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
state.lassoPoints = [];
state.lassoActive = true;
const coords = canvasCoords(e, state.mainCanvas);
@@ -56,10 +63,33 @@ export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndi
syncToolClearIndicators();
return;
}
// Keep the selection drawn — the panel's action buttons use it.
const layer = activeLayer?.();
if (!layer) return;
saveState?.('Lasso selection');
const mask = buildLassoMask(
state.lassoPoints,
state.imgWidth,
state.imgHeight,
0,
0,
0,
0,
);
commitSelectionMask?.(mask, layer, pendingMode, 'lasso', { x: 0, y: 0 });
state.wandMaskVisible = true;
state.wandLastSeed = null;
state.lassoPoints = [];
composite();
drawLassoOverlay();
syncToolClearIndicators();
},
cancel() {
if (!state.lassoActive && !state.lassoPoints.length) return false;
state.lassoActive = false;
state.lassoPoints = [];
composite();
syncToolClearIndicators();
return true;
},
};
}
+208
View File
@@ -0,0 +1,208 @@
/** Rectangle and ellipse marquee interaction producing a document-space mask. */
import { state } from '../state.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
import { canvasCoords } from '../canvas-coords.js';
import {
createMarqueeMask,
mergeSelectionMasks,
normalizeConstrainedSelectionRect,
selectionMaskBounds,
selectionMaskContains,
selectionMaskToDocument,
translateSelectionMask,
} from '../selection-mask.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createMarqueeTool({
activeLayer, saveState, composite, drawOverlay, syncSelectionUi,
ensureDocumentSelection, snapFrame,
}) {
let pendingMode = 'replace';
let moveHistorySaved = false;
const gesture = createDirectManipulationSession({
name: 'selection-boundary',
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'marquee',
onCancel: data => {
if (data?.mode === 'move' && state.selectionMoveOrigin) {
state.wandMask = translateSelectionMask(
state.selectionMoveOrigin, 0, 0, state.imgWidth, state.imgHeight,
);
state.wandMaskSpace = 'document';
if (moveHistorySaved && state.undoStack?.at(-1)?._label === 'Move selection boundary') {
state.undoStack.pop();
}
}
state.selectionMoving = false;
state.selectionMoveStart = null;
state.selectionMoveOrigin = null;
state.marqueeActive = false;
state.marqueeStart = null;
state.marqueeRect = null;
state.activeSnapGuides = null;
moveHistorySaved = false;
composite();
syncSelectionUi();
},
});
return {
begin(e) {
const layer = activeLayer();
if (!layer) return;
const coords = canvasCoords(e, state.mainCanvas);
const selection = ensureDocumentSelection?.() || null;
if (selection && !e.shiftKey && !e.altKey && selectionMaskContains(selection, coords.x, coords.y)) {
state.selectionMoving = true;
state.selectionMoveStart = coords;
state.selectionMoveOrigin = translateSelectionMask(selection, 0, 0, state.imgWidth, state.imgHeight);
moveHistorySaved = false;
gesture.begin(e, { mode: 'move' }, { captureTarget: e.currentTarget });
return;
}
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
state.marqueeStart = coords;
state.marqueeRect = normalizeConstrainedSelectionRect(
coords,
coords,
state.imgWidth,
state.imgHeight,
{
mode: state.marqueeConstraint,
ratioWidth: state.marqueeRatioWidth,
ratioHeight: state.marqueeRatioHeight,
fixedWidth: state.marqueeFixedWidth,
fixedHeight: state.marqueeFixedHeight,
},
);
state.marqueeActive = true;
gesture.begin(e, { mode: 'draw' }, { captureTarget: e.currentTarget });
},
drag(e) {
if (!gesture.update(e)) return;
if (state.selectionMoving && state.selectionMoveStart && state.selectionMoveOrigin) {
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
let dx = Math.round(coords.x - state.selectionMoveStart.x);
let dy = Math.round(coords.y - state.selectionMoveStart.y);
if (!dx && !dy) return;
const bounds = selectionMaskBounds(state.selectionMoveOrigin);
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (bounds && snapping && snapFrame) {
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
const snapped = snapFrame({
centerX: center.x,
centerY: center.y,
width: bounds.width,
height: bounds.height,
rotation: 0,
}, { x: center.x + dx, y: center.y + dy });
dx = Math.round(snapped.centerX - center.x);
dy = Math.round(snapped.centerY - center.y);
state.activeSnapGuides = snapped.guides || null;
} else {
state.activeSnapGuides = null;
}
if (!moveHistorySaved) {
saveState('Move selection boundary');
moveHistorySaved = true;
}
state.wandMask = translateSelectionMask(
state.selectionMoveOrigin,
dx,
dy,
state.imgWidth,
state.imgHeight,
);
state.wandMaskSpace = 'document';
state.wandLastSeed = null;
composite();
return;
}
if (!state.marqueeActive || !state.marqueeStart) return;
e.preventDefault();
state.marqueeRect = normalizeConstrainedSelectionRect(
state.marqueeStart,
canvasCoords(e, state.mainCanvas),
state.imgWidth,
state.imgHeight,
{
mode: state.marqueeConstraint,
ratioWidth: state.marqueeRatioWidth,
ratioHeight: state.marqueeRatioHeight,
fixedWidth: state.marqueeFixedWidth,
fixedHeight: state.marqueeFixedHeight,
square: !!e.shiftKey,
},
);
composite();
drawOverlay();
},
end(e) {
if (!gesture.commit(e)) return;
state.activeSnapGuides = null;
if (state.selectionMoving) {
state.selectionMoving = false;
state.selectionMoveStart = null;
state.selectionMoveOrigin = null;
moveHistorySaved = false;
composite();
syncSelectionUi();
return;
}
if (!state.marqueeActive) return;
state.marqueeActive = false;
const rect = state.marqueeRect;
state.marqueeStart = null;
state.marqueeRect = null;
if (!rect || rect.w < 1 || rect.h < 1) {
if (pendingMode === 'replace' && state.wandMask) {
saveState('Clear selection');
state.wandMask = null;
state.wandLayerId = null;
state.wandMaskSpace = 'layer';
state.selectionSource = null;
state.wandLastSeed = null;
}
composite();
syncSelectionUi();
return;
}
saveState(`${state.marqueeShape === 'ellipse' ? 'Ellipse' : 'Rectangle'} selection`);
const active = activeLayer();
const selectedLayer = state.layers.find(layer => layer.id === state.wandLayerId);
const selectedOffset = selectedLayer ? (state.layerOffsets.get(selectedLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
const current = selectionMaskToDocument(
state.wandMask,
state.wandMaskSpace || 'layer',
selectedOffset,
state.imgWidth,
state.imgHeight,
);
const incoming = createMarqueeMask(
state.imgWidth,
state.imgHeight,
rect,
state.marqueeShape,
);
state.wandMask = mergeSelectionMasks(current, incoming, pendingMode);
state.wandLayerId = active?.id || state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'marquee';
state.wandLastSeed = null;
state.wandMaskVisible = true;
state.lassoPoints = [];
state.lassoActive = false;
composite();
syncSelectionUi();
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+88 -10
View File
@@ -1,5 +1,5 @@
/**
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
* Move tool — drag a layer around the canvas, with configurable snapping
* to other layers' edges/centers and to canvas edges/center.
*
* Owns its own input handlers (begin/drag/end) and reads/writes the
@@ -12,40 +12,74 @@
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
* saveState: (label?: string) => void,
* composite: () => void,
* onPositionChange?: () => void,
* }} deps
* @returns {{ begin: (e: Event) => void, drag: (e: Event) => void, end: () => void }}
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { computeSnap as computeSnapImpl } from '../snap.js';
import { selectedLayers, selectionLabel } from '../layer-selection.js';
import { isLayerPositionLocked } from '../layer-groups.js';
import { clonePlacedData, translatePlacedData } from '../placed-layer.js';
export function createMoveTool({ activeLayer, saveState, composite }) {
export function createMoveTool({ activeLayer, saveState, composite, onPositionChange }) {
function computeSnap(layer, nx, ny) {
const selectedIds = new Set(state.selectedLayerIds || []);
return computeSnapImpl(layer, nx, ny, {
zoom: state.zoom,
canvasW: state.imgWidth,
canvasH: state.imgHeight,
otherLayers: state.layers.map(l => ({
otherLayers: state.layers.filter(l => !selectedIds.has(l.id)).map(l => ({
visible: l.visible,
id: l.id,
canvas: l.canvas,
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
})),
verticalGuides: state.guides?.vertical || [],
horizontalGuides: state.guides?.horizontal || [],
snapToGrid: !!state.snapToGrid,
gridSize: state.gridSize || 16,
});
}
return {
begin(e) {
const layer = activeLayer();
if (!layer || layer.locked) return;
saveState();
if (!layer || isLayerPositionLocked(state, layer)) return;
const activeMask = (layer.masks || []).find(mask =>
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
state.moving = true;
state.moveHistorySaved = false;
const coords = canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.moveStartX = coords.x;
state.moveStartY = coords.y;
state.moveLayerOffsetX = off.x;
state.moveLayerOffsetY = off.y;
state.moveMaskTarget = activeMask || null;
state.moveMaskOffsetX = Number(activeMask?.offset?.x) || 0;
state.moveMaskOffsetY = Number(activeMask?.offset?.y) || 0;
state.moveLayerOffsets = new Map(selectedLayers(state)
.filter(item => !isLayerPositionLocked(state, item))
.map(item => [
item.id,
{ ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
]));
state.movePlacedSources = new Map(selectedLayers(state)
.filter(item => item.kind === 'placed' && item.placed)
.map(item => [item.id, clonePlacedData(item.placed)]));
state.moveUnlinkedMaskOffsets = new Map();
for (const item of selectedLayers(state)) {
for (const mask of item.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
state.moveUnlinkedMaskOffsets.set(mask.id, {
mask,
x: Number(mask.offset?.x) || 0,
y: Number(mask.offset?.y) || 0,
});
}
}
},
drag(e) {
if (!state.moving) return;
@@ -55,12 +89,27 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
const coords = canvasCoords(e, state.mainCanvas);
const dx = coords.x - state.moveStartX;
const dy = coords.y - state.moveStartY;
if (state.moveMaskTarget) {
const mask = state.moveMaskTarget;
const nx = Math.round(state.moveMaskOffsetX + dx);
const ny = Math.round(state.moveMaskOffsetY + dy);
const before = mask.offset || { x: 0, y: 0 };
if (nx === before.x && ny === before.y) return;
if (!state.moveHistorySaved) {
saveState(`Move mask "${mask.name || 'Layer Mask'}"`);
state.moveHistorySaved = true;
}
mask.offset = { x: nx, y: ny };
state.activeSnapGuides = null;
composite();
return;
}
let nx = state.moveLayerOffsetX + dx;
let ny = state.moveLayerOffsetY + dy;
// Ctrl held = snap to canvas edges/center and to every other
// visible layer's edges/center. Opt-in to avoid a "sticky" feel
// during normal drags.
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd temporarily inverts the persistent Snap preference.
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping) {
const snapped = computeSnap(layer, nx, ny);
nx = snapped.x;
ny = snapped.y;
@@ -68,11 +117,40 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
} else {
state.activeSnapGuides = null;
}
state.layerOffsets.set(layer.id, { x: nx, y: ny });
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
if (nx === before.x && ny === before.y) return;
if (!state.moveHistorySaved) {
// Transform already owns one pre-session snapshot. A second Move
// snapshot would make Cancel restore the staged transform instead of
// the original layer.
if (!state.transformActive) saveState(`Move ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
state.moveHistorySaved = true;
}
const appliedDx = nx - state.moveLayerOffsetX;
const appliedDy = ny - state.moveLayerOffsetY;
for (const [id, start] of state.moveLayerOffsets) {
state.layerOffsets.set(id, { x: start.x + appliedDx, y: start.y + appliedDy });
const placedStart = state.movePlacedSources?.get(id);
const movedLayer = state.layers.find(item => item.id === id);
if (placedStart && movedLayer) movedLayer.placed = translatePlacedData(placedStart, appliedDx, appliedDy);
}
// An unlinked mask stays in the same document position while its layer
// moves. Its layer-relative offset therefore changes by the inverse
// layer delta. Linked masks retain their relative offset and move along.
for (const entry of state.moveUnlinkedMaskOffsets.values()) {
entry.mask.offset = { x: entry.x - appliedDx, y: entry.y - appliedDy };
}
const next = state.layerOffsets.get(layer.id) || { x: nx, y: ny };
composite();
onPositionChange?.(layer, before, next);
},
end() {
state.moving = false;
state.moveHistorySaved = false;
state.moveLayerOffsets = new Map();
state.movePlacedSources = new Map();
state.moveMaskTarget = null;
state.moveUnlinkedMaskOffsets = new Map();
state.activeSnapGuides = null;
},
};
+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();
},
};
}
+56 -10
View File
@@ -19,6 +19,7 @@
*
* @param {{
* saveState: (label: string) => void,
* undo: () => void,
* strokeTo: (x: number, y: number) => void,
* composite: () => void,
* getActiveMaskLayer: () => object | null,
@@ -31,20 +32,34 @@
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from '../layer-groups.js';
const STROKE_TOOLS = new Set(['brush', 'eraser', 'inpaint']);
const STROKE_TOOLS = new Set(['brush', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
function strokeLabel(tool) {
if (tool === 'brush') return 'Brush stroke';
if (tool === 'eraser') return 'Eraser stroke';
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
if (tool === 'smudge') return 'Smudge stroke';
if (tool === 'dodge') return 'Dodge stroke';
if (tool === 'burn') return 'Burn stroke';
return 'Stroke';
}
function pointerPressure(e) {
if (e?.touches?.[0] && Number.isFinite(e.touches[0].force) && e.touches[0].force > 0) return e.touches[0].force;
return Number.isFinite(e?.pressure) && e.pressure > 0 ? e.pressure : 1;
}
function eventSample(e) {
const coords = canvasCoords(e, state.mainCanvas);
return { x: coords.x, y: coords.y, pressure: pointerPressure(e) };
}
export function createStrokeTool({
saveState, strokeTo, composite,
saveState, undo, beginStroke, strokeTo, endStroke, composite,
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
renderLayerPanel, syncToolClearIndicators,
renderLayerPanel, syncToolClearIndicators, showToast,
}) {
return {
/**
@@ -53,6 +68,22 @@ export function createStrokeTool({
*/
tryBegin(e) {
if (!STROKE_TOOLS.has(state.tool)) return false;
const parent = activeParentLayer();
const activeMask = state.quickMaskActive && state.wandMask
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
: getActiveMaskLayer();
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
showToast?.('Rasterize the placed layer before painting its pixels');
return true;
}
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
showToast?.('Unlock image pixels before painting');
return true;
}
if (state.tool === 'eraser' && !activeMask && isLayerTransparencyLocked(state, parent)) {
showToast?.('Unlock transparent pixels before erasing');
return true;
}
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
// pressed at pointerdown flips the persistent toggle for one
// stroke only.
@@ -89,10 +120,7 @@ export function createStrokeTool({
}
saveState(strokeLabel(state.tool));
state.drawing = true;
const coords = canvasCoords(e, state.mainCanvas);
state.lastX = coords.x;
state.lastY = coords.y;
strokeTo(coords.x, coords.y);
beginStroke(eventSample(e), state.tool);
return true;
},
@@ -103,21 +131,39 @@ export function createStrokeTool({
tryContinue(e) {
if (!state.drawing) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
strokeTo(coords.x, coords.y);
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
const samples = events.length ? events : [e];
for (const sampleEvent of samples) strokeTo(eventSample(sampleEvent));
return true;
},
/**
* Wrap up an in-progress stroke. Returns true if there was one.
*/
tryEnd() {
tryEnd(e) {
if (!state.drawing) return false;
const wasDrawingInpaint = state.tool === 'inpaint';
state.drawing = false;
endStroke(e ? eventSample(e) : null);
composite();
// Strokes composite every frame while drawing, but the layer panel is
// intentionally not rebuilt at that frequency. Refresh once at the end
// so inline layer/group thumbnails reflect the completed edit.
renderLayerPanel();
if (wasDrawingInpaint) syncToolClearIndicators();
return true;
},
cancel() {
if (!state.drawing) return false;
state.drawing = false;
state.cloneSourceSnapshot = null;
undo?.();
state.redoStack = [];
composite();
renderLayerPanel();
syncToolClearIndicators();
return true;
},
};
}
+106 -70
View File
@@ -13,21 +13,37 @@
* tool and was handled (so the dispatcher can short-circuit).
*
* @param {{
* beginMove: (e: Event) => void,
* composite: () => void,
* drawTransformHandles: () => void,
* reapplyTransform: () => void,
* getTransformHandle: (x: number, y: number) => string | null,
* cursorForHandle: (id: string | null) => string,
* pointInTransformFrame: (x: number, y: number) => boolean,
* snapTransformFrame?: (frame: object, center: {x:number,y:number}) => object,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { resizeTransformFrame } from '../transform-frame-geometry.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createTransformDragTool({
beginMove, composite, drawTransformHandles, reapplyTransform,
getTransformHandle, cursorForHandle,
composite, drawTransformHandles, reapplyTransform,
getTransformHandle, cursorForHandle, pointInTransformFrame,
snapTransformFrame,
}) {
const gesture = createDirectManipulationSession({
name: 'transform-frame',
getContext: () => ({ canvas: state.mainCanvas, items: state.transformItems }),
isContextCurrent: context => state.transformActive &&
context.canvas === state.mainCanvas && context.items === state.transformItems,
onCancel: () => {
state.transformHandle = null;
state.activeSnapGuides = null;
composite();
drawTransformHandles();
},
});
return {
/**
* Called on pointerdown. Returns true if the transform tool handled
@@ -36,33 +52,36 @@ export function createTransformDragTool({
tryBegin(e) {
if (!state.transformActive) return false;
const coords = canvasCoords(e, state.mainCanvas);
state.transformHandle = getTransformHandle(coords.x, coords.y);
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
state.transformHandle = getTransformHandle(coords.x, coords.y, { pointerType });
if (state.transformHandle) {
state.transformStartX = coords.x;
state.transformStartY = coords.y;
// Snapshot offset + size at drag-start so each frame computes
// "start + dx" (correct delta) rather than accumulating off the
// running offset, which was making top/left grabs drift.
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.transformStartOffX = off.x;
state.transformStartOffY = off.y;
state.transformOrigW = layer.canvas.width;
state.transformOrigH = layer.canvas.height;
const center = state.transformCenter || { x: 0, y: 0 };
state.transformStartCenter = { ...center };
state.transformOrigW = state.transformPendingW;
state.transformOrigH = state.transformPendingH;
state.transformStartRotation = state.transformPendingRot;
state.transformStartFlipH = state.transformPendingFlipH;
state.transformStartFlipV = state.transformPendingFlipV;
state.transformStartOffX = center.x - state.transformOrigW / 2;
state.transformStartOffY = center.y - state.transformOrigH / 2;
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
return true;
}
// No corner hit — if click inside the layer's bounding box, act
// like Move so the user can drag the layer around without
// switching tools.
if (state.transformLayer) {
const off = state.layerOffsets.get(state.transformLayer.id) || { x: 0, y: 0 };
const w = state.transformLayer.canvas.width;
const h = state.transformLayer.canvas.height;
if (coords.x >= off.x && coords.x <= off.x + w &&
coords.y >= off.y && coords.y <= off.y + h) {
beginMove(e);
return true;
}
// No handle hit: dragging inside the staged frame moves the transform
// itself. Keeping this in the transform session is essential for
// selection transforms, which must never fall through to layer Move.
if (pointInTransformFrame?.(coords.x, coords.y)) {
state.transformHandle = 'move';
state.transformStartX = coords.x;
state.transformStartY = coords.y;
state.transformStartCenter = { ...(state.transformCenter || { x: 0, y: 0 }) };
gesture.begin(e, { handle: 'move' }, { captureTarget: e.currentTarget });
return true;
}
return false;
},
@@ -79,8 +98,12 @@ export function createTransformDragTool({
// No drag in progress — just hover-cursor + pulse.
if (!state.transformHandle && state.mainCanvas) {
const coords = canvasCoords(e, state.mainCanvas);
const hovered = getTransformHandle(coords.x, coords.y);
state.mainCanvas.style.cursor = hovered ? cursorForHandle(hovered) : 'default';
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
const hovered = getTransformHandle(coords.x, coords.y, { pointerType });
const bodyHovered = !hovered && pointInTransformFrame?.(coords.x, coords.y);
state.mainCanvas.style.cursor = hovered
? cursorForHandle(hovered, state.transformPendingRot)
: bodyHovered ? 'move' : 'default';
if (hovered !== state.hoveredHandle) {
state.hoveredHandle = hovered;
composite();
@@ -88,15 +111,38 @@ export function createTransformDragTool({
return false; // didn't fully consume the event
}
if (!state.transformHandle) return false;
if (!gesture.update(e)) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
if (state.transformHandle === 'move') {
let center = {
x: state.transformStartCenter.x + coords.x - state.transformStartX,
y: state.transformStartCenter.y + coords.y - state.transformStartY,
};
const modifier = e.ctrlKey || e.metaKey;
const snapping = state.snapEnabled ? !modifier : modifier;
if (snapping && snapTransformFrame) {
const snapped = snapTransformFrame({
centerX: state.transformStartCenter.x,
centerY: state.transformStartCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, center);
center = { x: snapped.centerX, y: snapped.centerY };
state.activeSnapGuides = snapped.guides || null;
} else {
state.activeSnapGuides = null;
}
state.transformCenter = center;
reapplyTransform();
return true;
}
// Rotation grip — angle measured from the layer's geometric
// centre to the cursor. Mirror into the popup if it's open.
if (state.transformHandle === 'rot') {
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const cx = off.x + layer.canvas.width / 2;
const cy = off.y + layer.canvas.height / 2;
const cx = state.transformCenter?.x || 0;
const cy = state.transformCenter?.y || 0;
const rad = Math.atan2(coords.y - cy, coords.x - cx) + Math.PI / 2;
let deg = Math.round((rad * 180) / Math.PI);
if (e.shiftKey) deg = Math.round(deg / 15) * 15; // 15° snap
@@ -110,50 +156,35 @@ export function createTransformDragTool({
}
return true;
}
// Resize via corner / edge handle.
const dx = coords.x - state.transformStartX;
const dy = coords.y - state.transformStartY;
const layer = state.transformLayer;
let newW = layer.canvas.width;
let newH = layer.canvas.height;
if (state.transformHandle.includes('r')) newW = state.transformOrigW + dx;
if (state.transformHandle.includes('l')) newW = state.transformOrigW - dx;
if (state.transformHandle.includes('b')) newH = state.transformOrigH + dy;
if (state.transformHandle.includes('t')) newH = state.transformOrigH - dy;
// Shift = lock aspect ratio. Use whichever axis moved more
// (relative to the original) as the driver.
if (e.shiftKey && state.transformOrigW > 0 && state.transformOrigH > 0) {
const aspect = state.transformOrigW / state.transformOrigH;
const wDelta = Math.abs(newW - state.transformOrigW);
const hDelta = Math.abs(newH - state.transformOrigH);
if (wDelta >= hDelta) {
newH = Math.max(1, Math.round(newW / aspect));
} else {
newW = Math.max(1, Math.round(newH * aspect));
}
}
newW = Math.max(1, Math.round(newW));
newH = Math.max(1, Math.round(newH));
// Route through the popup-driven pipeline so popup + drag stay
// in sync. Anchor the opposite corner via transformOrigOffset so
// handles don't slide while the user drags.
state.transformPendingW = newW;
state.transformPendingH = newH;
const anchorOffX = state.transformStartOffX +
(state.transformHandle.includes('l') ? (state.transformOrigW - newW) : 0);
const anchorOffY = state.transformStartOffY +
(state.transformHandle.includes('t') ? (state.transformOrigH - newH) : 0);
state.transformOrigOffset = {
x: anchorOffX + newW / 2 - state.transformOrigW / 2,
y: anchorOffY + newH / 2 - state.transformOrigH / 2,
};
// Resize in the frame's LOCAL axes. This keeps rotated side handles
// attached to the pointer and fixes the opposite edge in document space.
const resized = resizeTransformFrame({
centerX: state.transformStartCenter.x,
centerY: state.transformStartCenter.y,
width: state.transformOrigW,
height: state.transformOrigH,
rotation: state.transformStartRotation,
}, state.transformHandle, {
x: state.transformStartX,
y: state.transformStartY,
}, coords, {
centered: !!e.altKey,
lockAspect: !!e.shiftKey,
flipH: state.transformStartFlipH,
flipV: state.transformStartFlipV,
});
state.transformPendingW = resized.width;
state.transformPendingH = resized.height;
state.transformPendingFlipH = resized.flipH;
state.transformPendingFlipV = resized.flipV;
state.transformCenter = { x: resized.centerX, y: resized.centerY };
reapplyTransform();
// Mirror the new W/H into the popup if it's open.
if (state.transformPopup) {
const wIn = state.transformPopup.querySelector('#ge-transform-w');
const hIn = state.transformPopup.querySelector('#ge-transform-h');
if (wIn) wIn.value = String(state.transformPendingFlipH ? -newW : newW);
if (hIn) hIn.value = String(state.transformPendingFlipV ? -newH : newH);
if (wIn) wIn.value = String(state.transformPendingFlipH ? -resized.width : resized.width);
if (hIn) hIn.value = String(state.transformPendingFlipV ? -resized.height : resized.height);
}
return true;
},
@@ -161,14 +192,19 @@ export function createTransformDragTool({
/**
* Called on pointerup. Returns true if handled.
*/
tryEnd() {
tryEnd(e) {
if (!(state.transformActive && state.transformHandle)) return false;
if (!gesture.commit(e)) return false;
state.transformHandle = null;
state.transformOrigW = state.transformLayer?.canvas.width || 0;
state.transformOrigH = state.transformLayer?.canvas.height || 0;
state.transformOrigW = state.transformPendingW;
state.transformOrigH = state.transformPendingH;
state.activeSnapGuides = null;
composite();
drawTransformHandles();
return true;
},
cancel(reason) {
return gesture.cancel(reason);
},
};
}
+129 -138
View File
@@ -8,8 +8,8 @@
*
* - `syncOverlay(margin)` positions the overlay canvas + sizes its
* bitmap based on the main canvas + zoom.
* - `drawHandles(margin)` draws the rotated bounding outline + 4
* corner handles + the rotation knob (with
* - `drawHandles(margin)` draws the rotated bounding outline + 8
* resize handles + the rotation knob (with
* hover / active visual states).
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
* null. Geometry MUST mirror `drawHandles`
@@ -20,13 +20,16 @@
* pointer events.
*/
import { state } from '../state.js';
import {
hitTestTransformHandle,
pointInTransformFrame,
transformFrameGeometry,
} from '../transform-frame-geometry.js';
/**
* Position the transform overlay canvas + size its backing bitmap.
* Margin is the image-space slack each side so handles can render
* outside the main canvas (matches _TRANSFORM_OVERLAY_MARGIN in
* galleryEditor.js — kept as a parameter so this module has no
* dependency on a magic number defined elsewhere).
* Margin is screen-space slack on each side so controls remain visible
* at every zoom. It is converted to image pixels for the backing canvas.
*/
export function syncOverlay(margin) {
if (!state.transformOverlay || !state.mainCanvas) return;
@@ -34,8 +37,9 @@ export function syncOverlay(margin) {
state.transformOverlay.style.display = 'none';
return;
}
const W = state.mainCanvas.width + 2 * margin;
const H = state.mainCanvas.height + 2 * margin;
const imageMargin = margin / Math.max(0.0001, state.zoom);
const W = Math.ceil(state.mainCanvas.width + 2 * imageMargin);
const H = Math.ceil(state.mainCanvas.height + 2 * imageMargin);
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
// Overlay must scale with state.zoom so its handles render at the
@@ -55,103 +59,100 @@ export function syncOverlay(margin) {
// both canvas + overlay), so pan moves them together. Reading the
// layout offset (not getBoundingClientRect, which includes the pan
// transform) is what avoids the double-pan "bounce".
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin * state.zoom) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin * state.zoom) + 'px';
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
}
/**
* Compute the on-screen position of the rotation knob given the
* layer's bbox center + rotation. The knob normally sits OUTSIDE the
* top edge of the rotated layer; if that would land beyond the canvas
* viewport, flip it INSIDE.
*
* Returned by `_knobPosition` and shared by drawHandles + getHandleAt
* so both compute the same point.
*/
function knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset) {
let rotInside = false;
const outsideR = baseInnerR + rotOffset;
const knobLocalX = cxh + Math.sin(rotRad) * outsideR;
const knobLocalY = cyh - Math.cos(rotRad) * outsideR;
// Primary check: anything drawn outside the main canvas's pixel
// buffer is invisible (canvas operations clip silently).
if (
knobLocalX < 0 || knobLocalY < 0 ||
knobLocalX > state.mainCanvas.width || knobLocalY > state.mainCanvas.height
) {
rotInside = true;
}
// Secondary check: even if the knob is inside the canvas bitmap, the
// viewport may have scrolled the canvas such that the knob falls
// outside the visible canvas-area window.
function rotationHandleInside(frame) {
let inside = false;
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
// The overlay deliberately extends beyond the image, so only fold the
// rotation control inward when the surrounding viewport would clip it.
try {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (area && !rotInside) {
if (area && !inside) {
const aRect = area.getBoundingClientRect();
const mRect = state.mainCanvas.getBoundingClientRect();
const scaleX = mRect.width / state.mainCanvas.width;
const scaleY = mRect.height / state.mainCanvas.height;
const knobClientX = mRect.left + knobLocalX * scaleX;
const knobClientY = mRect.top + knobLocalY * scaleY;
if (knobClientY < aRect.top + 6) rotInside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) rotInside = true;
const knobClientX = mRect.left + outside.x * scaleX;
const knobClientY = mRect.top + outside.y * scaleY;
if (knobClientY < aRect.top + 6) inside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) inside = true;
const popupRect = state.transformPopup?.getBoundingClientRect?.();
if (popupRect
&& knobClientX >= popupRect.left
&& knobClientX <= popupRect.right
&& knobClientY >= popupRect.top
&& knobClientY <= popupRect.bottom) {
inside = true;
}
}
} catch {}
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
return inside;
}
function transformFrame() {
if (state.transformTarget === 'selection') {
if (!state.transformSelectionBounds) return null;
return {
centerX: state.transformCenter?.x ?? state.transformSelectionBounds.centerX,
centerY: state.transformCenter?.y ?? state.transformSelectionBounds.centerY,
width: state.transformPendingW || state.transformSelectionBounds.width,
height: state.transformPendingH || state.transformSelectionBounds.height,
rotation: state.transformPendingRot || 0,
};
}
const layer = state.transformLayer;
if (!layer) return null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const fallbackCenter = {
x: off.x + layer.canvas.width / 2,
y: off.y + layer.canvas.height / 2,
};
return {
rotInside,
innerR,
rotX: cxh + Math.sin(rotRad) * rotR,
rotY: cyh - Math.cos(rotRad) * rotR,
centerX: state.transformCenter?.x ?? fallbackCenter.x,
centerY: state.transformCenter?.y ?? fallbackCenter.y,
width: state.transformPendingW || layer.canvas.width,
height: state.transformPendingH || layer.canvas.height,
rotation: state.transformPendingRot || 0,
};
}
/**
* Draw the rotated bounding outline + 4 corner handles + the rotation
* Draw the rotated bounding outline + 8 resize handles + the rotation
* knob into the overlay canvas. The overlay is translated by `margin`
* so image (0,0) maps to overlay (margin, margin).
*/
export function drawHandles(margin) {
if (!state.transformActive || !state.transformLayer) return;
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
syncOverlay(margin);
if (!state.transformOverlayCtx) return;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const frame = transformFrame();
if (!frame) return;
const rotInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: rotInside,
});
const ctx = state.transformOverlayCtx;
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
const imageMargin = margin / Math.max(0.0001, state.zoom);
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
ctx.save();
ctx.translate(margin, margin);
ctx.translate(imageMargin, imageMargin);
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
const sz = 10 / state.zoom;
const stroke = 1.5 / state.zoom;
// Pre-rotation rectangle dims (what the user sees the layer as).
// Falls back to layer bbox before any popup values exist.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cxBox = off.x + w / 2;
const cyBox = off.y + h / 2;
const rotRadBox = ((state.transformPendingRot || 0) * Math.PI) / 180;
const cosBox = Math.cos(rotRadBox);
const sinBox = Math.sin(rotRadBox);
const rotPt = (dx, dy) => ({
x: cxBox + dx * cosBox - dy * sinBox,
y: cyBox + dx * sinBox + dy * cosBox,
});
const tl = rotPt(-preW / 2, -preH / 2);
const tr = rotPt( preW / 2, -preH / 2);
const br = rotPt( preW / 2, preH / 2);
const bl = rotPt(-preW / 2, preH / 2);
const { tl, tr, br, bl } = geometry.corners;
// Outline of the rotated rectangle — solid white inner line with a
// thin black halo for contrast on light AND dark backgrounds.
// Use the editor accent instead of white so the frame remains distinct
// from both the canvas and the ruler/UI chrome.
const drawRectOutline = () => {
ctx.beginPath();
ctx.moveTo(tl.x, tl.y);
@@ -161,54 +162,41 @@ export function drawHandles(margin) {
ctx.closePath();
ctx.stroke();
};
ctx.lineWidth = 1 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.lineWidth = 3 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.82)';
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
ctx.lineDashOffset = 1 / state.zoom;
drawRectOutline();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 / state.zoom;
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
ctx.lineDashOffset = 0;
drawRectOutline();
ctx.setLineDash([]);
// Corner handles + rotation knob anchored to the rotated layer's
// Resize handles + rotation knob anchored to the rotated layer's
// top-center (not bbox top), so the knob stays attached to the
// visible content as it spins.
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
// Tether line collapses to a point when knob is inside the layer.
const drawTether = !knob.rotInside;
const innerX = cxh + Math.sin(rotRad) * baseInnerR;
const innerY = cyh - Math.cos(rotRad) * baseInnerR;
const corners = [
{ x: tl.x, y: tl.y, id: 'tl' },
{ x: tr.x, y: tr.y, id: 'tr' },
{ x: br.x, y: br.y, id: 'br' },
{ x: bl.x, y: bl.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
if (drawTether) {
const topHandle = geometry.resizeHandles.find(handle => handle.id === 't');
if (!rotInside) {
ctx.beginPath();
ctx.moveTo(innerX, innerY);
ctx.lineTo(knob.rotX, knob.rotY);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.moveTo(topHandle.x, topHandle.y);
ctx.lineTo(geometry.rotationHandle.x, geometry.rotationHandle.y);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
}
for (const c of corners) {
for (const c of geometry.handles) {
const active = c.id === state.transformHandle;
const hovered = !active && c.id === state.hoveredHandle;
const radius = (active ? sz * 0.75 : hovered ? sz * 0.6 : sz / 2);
const edge = c.id.length === 1 && c.id !== 'rot';
const baseRadius = edge ? sz * 0.42 : sz / 2;
const radius = active ? sz * 0.75 : hovered ? sz * 0.6 : baseRadius;
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
ctx.fillStyle = active ? '#fff' : hovered ? '#ffd' : accent;
ctx.fill();
ctx.lineWidth = stroke;
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
ctx.strokeStyle = active ? accent : 'rgba(0, 0, 0, 0.72)';
ctx.stroke();
if (hovered) {
// Subtle red ring around the hovered handle for visual feedback.
@@ -219,53 +207,56 @@ export function drawHandles(margin) {
ctx.stroke();
}
}
// The center marker communicates the rotation pivot without becoming an
// interactive handle until pivot dragging lands in the transform workflow.
const pivotRadius = 3 / state.zoom;
ctx.beginPath();
ctx.arc(geometry.pivot.x, geometry.pivot.y, pivotRadius, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
ctx.restore();
}
/**
* Hit-test (x, y) against the transform handles. Returns the handle
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
* id ('tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | 'rot') or null.
*
* Geometry MUST mirror `drawHandles` exactly, otherwise the user
* grabs phantom points.
*/
export function getHandleAt(x, y) {
if (!state.transformLayer) return null;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const threshold = 8 / state.zoom;
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
export function getHandleAt(x, y, options = {}) {
if (state.transformTarget !== 'selection' && !state.transformLayer) return null;
const frame = transformFrame();
if (!frame) return null;
const rotationInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside,
});
const hit = hitTestTransformHandle(geometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
if (hit || !rotationInside) return hit;
// Rotate corners around centre — must match drawHandles.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cosA = Math.cos(rotRad);
const sinA = Math.sin(rotRad);
const rotCorner = (dx, dy) => ({
x: cxh + dx * cosA - dy * sinA,
y: cyh + dx * sinA + dy * cosA,
// Keep the clipped grip reachable during viewport-edge transitions. The
// frame is drawn with the inward position, but a touch can begin from the
// previously rendered outward position for one frame after a resize/pan.
const unclippedGeometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: false,
});
return hitTestTransformHandle(unclippedGeometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
const tlH = rotCorner(-preW / 2, -preH / 2);
const trH = rotCorner( preW / 2, -preH / 2);
const brH = rotCorner( preW / 2, preH / 2);
const blH = rotCorner(-preW / 2, preH / 2);
const handles = [
{ x: tlH.x, y: tlH.y, id: 'tl' },
{ x: trH.x, y: trH.y, id: 'tr' },
{ x: brH.x, y: brH.y, id: 'br' },
{ x: blH.x, y: blH.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
for (const c of handles) {
if (Math.abs(x - c.x) < threshold && Math.abs(y - c.y) < threshold) return c.id;
}
return null;
}
/** True only for the visible rotated frame, not its empty AABB corners. */
export function containsFramePoint(x, y) {
const frame = transformFrame();
if (!frame) return false;
return pointInTransformFrame(transformFrameGeometry(frame, { zoom: state.zoom }), x, y);
}
+427 -60
View File
@@ -23,31 +23,134 @@
* hideCanvasLoading: () => void,
* undo: () => void,
* uiModule: object | null,
* syncGeometryControls?: () => void,
* getDocumentSelection?: () => HTMLCanvasElement | null,
* syncSelectionUi?: () => void,
* schedulePersist?: () => void,
* }} deps
*
* @returns {{
* startTransform, openTransformPopup, closeTransformPopup,
* reapplyTransform, confirmTransform, cancelTransform,
* reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
* }}
*/
import { state } from '../state.js';
import { normalizeTextData, renderTextLayer } from '../text-layer.js';
import { normalizeShapeData, renderShapeLayer } from '../shape-layer.js';
import { isLayerPositionLocked } from '../layer-groups.js';
import { selectedLayers, selectionLabel } from '../layer-selection.js';
import {
planLayerTransform,
selectionBounds,
transformedSelectionBounds,
} from '../multi-transform.js';
import {
EDITOR_MAX_DIMENSION,
EDITOR_MAX_SURFACE_PIXELS,
} from '../document-codec.js';
import {
transformPopupHTML,
attachSpinRepeat,
} from '../build/transform-popup.js';
import {
selectionMaskBounds,
transformSelectionMask,
} from '../selection-mask.js';
import {
clonePlacedData,
createPlacedData,
frameTransformMatrix,
renderPlacedLayer,
transformPlacedData,
} from '../placed-layer.js';
export function createTransformSession({
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
showCanvasLoading, hideCanvasLoading, undo, uiModule,
syncGeometryControls, getDocumentSelection, syncSelectionUi, schedulePersist,
}) {
let lastValidLayerTarget = null;
let lastTransformLimitMessage = '';
const formatTransformValue = value => {
const number = Number(value) || 0;
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)));
};
function syncTransformPopupValues(force = false) {
const pop = state.transformPopup;
if (!pop || !state.transformCenter) return;
const values = {
'#ge-transform-x': state.transformCenter.x,
'#ge-transform-y': state.transformCenter.y,
'#ge-transform-w': state.transformPendingFlipH ? -state.transformPendingW : state.transformPendingW,
'#ge-transform-h': state.transformPendingFlipV ? -state.transformPendingH : state.transformPendingH,
'#ge-transform-rot': state.transformPendingRot,
};
for (const [selector, value] of Object.entries(values)) {
const input = pop.querySelector(selector);
if (input && (force || document.activeElement !== input)) input.value = formatTransformValue(value);
}
}
function copyCanvas(source) {
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
function startTransform() {
const layer = activeLayer();
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
const layers = selectedLayers(state);
if (!layer || !layers.length) { uiModule.showToast('Select an unlocked layer'); return; }
if (layers.some(item => isLayerPositionLocked(state, item))) {
uiModule.showToast(layers.length > 1 ? 'Unlock the position of every selected layer before transforming' : 'Unlock the layer position before transforming');
return;
}
if (state.transformActive) { cancelTransform(); return; } // toggle off
const bounds = selectionBounds(state, layers);
state.transformActive = true;
state.transformTarget = 'layers';
state.transformLayer = layer;
state.transformOrigW = layer.canvas.width;
state.transformOrigH = layer.canvas.height;
state.transformLayers = [...layers];
state.transformItems = layers.map(item => ({
layer: item,
kind: item.kind || 'raster',
canvas: copyCanvas(item.canvas),
width: item.canvas.width,
height: item.canvas.height,
offset: { ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
wasPlaced: item.kind === 'placed',
placed: item.kind === 'placed'
? clonePlacedData(item.placed)
: (item.kind === 'raster' ? createPlacedData(item.canvas, [1, 0, 0, 1, 0, 0], item.name || 'Raster layer') : null),
masks: (item.masks || [])
.filter(mask => (mask.space || 'layer') === 'layer' && mask.canvas)
.map(mask => {
const maskOffset = {
x: Number(mask.offset?.x) || 0,
y: Number(mask.offset?.y) || 0,
};
const layerOffset = state.layerOffsets.get(item.id) || { x: 0, y: 0 };
return {
mask,
canvas: copyCanvas(mask.canvas),
linked: mask.linked !== false,
offset: maskOffset,
documentOffset: {
x: layerOffset.x + maskOffset.x,
y: layerOffset.y + maskOffset.y,
},
};
}),
}));
const activeSnapshot = state.transformItems.find(item => item.layer.id === layer.id);
state.transformOrigW = bounds.width;
state.transformOrigH = bounds.height;
state.transformSessionStartW = bounds.width;
state.transformSessionStartH = bounds.height;
state.transformPendingW = state.transformOrigW;
state.transformPendingH = state.transformOrigH;
state.transformPendingRot = 0;
@@ -56,12 +159,16 @@ export function createTransformSession({
// Snapshot the layer so live preview can re-derive from the
// original pixels on every keystroke instead of stacking
// destructive edits.
state.transformOrigCanvas = document.createElement('canvas');
state.transformOrigCanvas.width = state.transformOrigW;
state.transformOrigCanvas.height = state.transformOrigH;
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
saveState();
state.transformOrigCanvas = activeSnapshot?.canvas || null;
state.transformOrigMasks = activeSnapshot?.masks || [];
state.transformOrigOffset = { ...(activeSnapshot?.offset || { x: 0, y: 0 }) };
state.transformSelectionBounds = { ...bounds };
state.transformBounds = { ...bounds };
state.transformCenter = { x: bounds.centerX, y: bounds.centerY };
state.transformStartCenter = { ...state.transformCenter };
lastValidLayerTarget = currentTransformTarget();
lastTransformLimitMessage = '';
saveState(`Transform ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
// Fit canvas to viewport so the corner handles are visible —
// without this, a layer larger than the viewport leaves the grab
// markers off-screen.
@@ -69,6 +176,48 @@ export function createTransformSession({
composite();
drawTransformHandles();
openTransformPopup();
syncGeometryControls?.();
}
function startSelectionTransform() {
if (state.transformActive) cancelTransform();
const selection = getDocumentSelection?.();
const bounds = selectionMaskBounds(selection);
if (!selection || !bounds) {
uiModule?.showToast('Make a selection before transforming it');
return false;
}
const frame = {
...bounds,
centerX: bounds.x + bounds.width / 2,
centerY: bounds.y + bounds.height / 2,
};
state.transformActive = true;
state.transformTarget = 'selection';
state.transformLayer = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionCanvas = copyCanvas(selection);
state.transformSelectionBounds = { ...frame };
state.transformBounds = { ...frame };
state.transformCenter = { x: frame.centerX, y: frame.centerY };
state.transformStartCenter = { ...state.transformCenter };
state.transformOrigW = frame.width;
state.transformOrigH = frame.height;
state.transformSessionStartW = frame.width;
state.transformSessionStartH = frame.height;
state.transformPendingW = frame.width;
state.transformPendingH = frame.height;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
saveState('Transform selection');
try { fitZoom(); } catch {}
composite();
drawTransformHandles();
openTransformPopup();
syncSelectionUi?.();
return true;
}
function closeTransformPopup() {
@@ -85,19 +234,39 @@ export function createTransformSession({
function openTransformPopup() {
closeTransformPopup();
if (!state.container) return;
// Global tours and toasts sit above tool popups and can cover the compact
// mobile header or Apply/Cancel controls. Starting a new edit is a stronger
// signal than those transient messages, so clear them first.
document.querySelector('.tour-hint .tour-hint-dismiss')?.click();
document.querySelector('#toast.show .toast-close-btn')?.click();
const pop = document.createElement('div');
pop.className = 'ge-transform-popup';
pop.innerHTML = transformPopupHTML();
const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
else if (title && state.transformLayers.length > 1) title.textContent = `Transform ${state.transformLayers.length} layers`;
state.container.appendChild(pop);
state.transformPopup = pop;
wireTransformDrag(pop);
const wInput = pop.querySelector('#ge-transform-w');
const hInput = pop.querySelector('#ge-transform-h');
const rotInput = pop.querySelector('#ge-transform-rot');
const xInput = pop.querySelector('#ge-transform-x');
const yInput = pop.querySelector('#ge-transform-y');
const aspectBtn = pop.querySelector('#ge-transform-aspect');
wInput.value = String(state.transformOrigW);
hInput.value = String(state.transformOrigH);
rotInput.value = '0';
const preserveSource = pop.querySelector('#ge-transform-preserve-source');
const preserveField = pop.querySelector('.ge-transform-preserve-field');
const canPreserveSource = state.transformTarget !== 'selection'
&& state.transformItems.some(item => item.layer?.kind === 'raster' || item.wasPlaced);
if (preserveField) preserveField.hidden = !canPreserveSource;
if (preserveSource) {
preserveSource.checked = state.transformPreserveSource !== false;
preserveSource.addEventListener('change', () => {
state.transformPreserveSource = preserveSource.checked;
reapplyTransform();
});
}
syncTransformPopupValues();
aspectBtn.classList.toggle('active', state.transformAspectLock);
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
@@ -125,6 +294,8 @@ export function createTransformSession({
let w = parseInt(wInput.value, 10);
let h = parseInt(hInput.value, 10);
const rot = parseInt(rotInput.value, 10) || 0;
const x = Number.parseFloat(xInput.value);
const y = Number.parseFloat(yInput.value);
state.transformPendingFlipH = w < 0;
state.transformPendingFlipV = h < 0;
w = Math.abs(w || state.transformOrigW);
@@ -132,8 +303,11 @@ export function createTransformSession({
state.transformPendingW = Math.max(1, w);
state.transformPendingH = Math.max(1, h);
state.transformPendingRot = rot;
if (Number.isFinite(x) && Number.isFinite(y)) state.transformCenter = { x, y };
reapplyTransform();
};
xInput.addEventListener('input', refresh);
yInput.addEventListener('input', refresh);
wInput.addEventListener('input', () => {
if (state.transformAspectLock) {
driver = 'w';
@@ -310,72 +484,265 @@ export function createTransformSession({
document.addEventListener('touchcancel', endDrag);
}
// Re-derive the active layer's pixels from the original snapshot
// with the popup's current W/H/flip/rotation applied. Cheap —
// paints into an off-screen canvas of the final size.
function renderSnapshot(source, snapshot, geometry) {
const canvas = document.createElement('canvas');
canvas.width = geometry.width;
canvas.height = geometry.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (geometry.rotation) ctx.rotate(geometry.radians);
ctx.scale(geometry.signedScaleX, geometry.signedScaleY);
ctx.drawImage(source, -snapshot.width / 2, -snapshot.height / 2, snapshot.width, snapshot.height);
ctx.restore();
return canvas;
}
function currentTransformTarget() {
return {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
centerX: state.transformCenter?.x,
centerY: state.transformCenter?.y,
};
}
function restoreTransformTarget(target) {
if (!target) return;
state.transformPendingW = target.width;
state.transformPendingH = target.height;
state.transformPendingRot = target.rotation;
state.transformPendingFlipH = target.flipH;
state.transformPendingFlipV = target.flipV;
state.transformCenter = { x: target.centerX, y: target.centerY };
}
// Re-derive every selected layer from immutable per-session snapshots.
function reapplyTransform() {
const layer = state.transformLayer;
if (!layer || !state.transformOrigCanvas) return;
const w = state.transformPendingW;
const h = state.transformPendingH;
const rotDeg = state.transformPendingRot;
const rotRad = (rotDeg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rotRad));
const sin = Math.abs(Math.sin(rotRad));
// Bounding box of the rotated W×H — canvas grows so corners
// don't clip.
const finalW = Math.max(1, Math.round(w * cos + h * sin));
const finalH = Math.max(1, Math.round(w * sin + h * cos));
const tmp = document.createElement('canvas');
tmp.width = finalW; tmp.height = finalH;
const tCtx = tmp.getContext('2d');
tCtx.imageSmoothingEnabled = true;
tCtx.imageSmoothingQuality = 'high';
tCtx.save();
tCtx.translate(finalW / 2, finalH / 2);
if (rotDeg) tCtx.rotate(rotRad);
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
tCtx.restore();
layer.canvas.width = finalW;
layer.canvas.height = finalH;
layer.ctx.clearRect(0, 0, finalW, finalH);
layer.ctx.drawImage(tmp, 0, 0);
// Recenter the layer so the rotation pivot stays put visually.
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
state.layerOffsets.set(layer.id, {
x: Math.round(origCenterX - finalW / 2),
y: Math.round(origCenterY - finalH / 2),
});
if (state.transformTarget === 'selection') {
if (!state.transformSelectionCanvas || !state.transformSelectionBounds || !state.transformCenter) return;
const target = {
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
flipH: state.transformPendingFlipH,
flipV: state.transformPendingFlipV,
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
};
state.wandMask = transformSelectionMask(
state.transformSelectionCanvas,
state.transformSelectionBounds,
target,
state.imgWidth,
state.imgHeight,
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'transform';
state.wandLastSeed = null;
state.lassoPoints = [];
state.lassoActive = false;
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncSelectionUi?.();
drawTransformHandles();
return;
}
if (!state.transformItems.length || !state.transformSelectionBounds || !state.transformCenter) return;
const target = currentTransformTarget();
const plan = planLayerTransform(
state,
state.transformItems,
state.transformSelectionBounds,
target,
{ maxDimension: EDITOR_MAX_DIMENSION, maxSurfacePixels: EDITOR_MAX_SURFACE_PIXELS },
);
if (!plan.ok) {
restoreTransformTarget(lastValidLayerTarget);
syncTransformPopupValues(true);
if (lastTransformLimitMessage !== plan.reason) {
uiModule?.showToast(plan.reason, 5000);
lastTransformLimitMessage = plan.reason;
}
composite();
drawTransformHandles();
return false;
}
lastValidLayerTarget = { ...target };
lastTransformLimitMessage = '';
const documentTransform = frameTransformMatrix(state.transformSelectionBounds, target);
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
const layer = snapshot.layer;
let geometry = plannedGeometry;
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
const rendered = renderPlacedLayer(layer);
geometry = {
...plannedGeometry,
width: rendered.canvas.width,
height: rendered.canvas.height,
offset: rendered.offset,
};
} else {
if (snapshot.kind === 'raster') {
layer.kind = 'raster';
layer.placed = null;
}
const output = renderSnapshot(snapshot.canvas, snapshot, geometry);
layer.canvas.width = geometry.width;
layer.canvas.height = geometry.height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.drawImage(output, 0, 0);
}
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._adjCache = null;
layer._adjCacheKey = null;
state.layerOffsets.set(layer.id, geometry.offset);
for (const maskSnapshot of snapshot.masks) {
if (maskSnapshot.linked) {
const positioned = document.createElement('canvas');
positioned.width = snapshot.width;
positioned.height = snapshot.height;
positioned.getContext('2d').drawImage(
maskSnapshot.canvas,
maskSnapshot.offset.x,
maskSnapshot.offset.y,
);
const maskOutput = renderSnapshot(positioned, snapshot, geometry);
maskSnapshot.mask.canvas.width = geometry.width;
maskSnapshot.mask.canvas.height = geometry.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskOutput, 0, 0);
maskSnapshot.mask.offset = { x: 0, y: 0 };
} else {
// Rebuild from the immutable snapshot so every preview remains
// cancel-safe, then compensate for the layer's new document offset.
maskSnapshot.mask.canvas.width = maskSnapshot.canvas.width;
maskSnapshot.mask.canvas.height = maskSnapshot.canvas.height;
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
maskSnapshot.mask.ctx.drawImage(maskSnapshot.canvas, 0, 0);
maskSnapshot.mask.offset = {
x: maskSnapshot.documentOffset.x - geometry.offset.x,
y: maskSnapshot.documentOffset.y - geometry.offset.y,
};
}
}
}
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
composite();
syncTransformPopupValues();
syncGeometryControls?.();
drawTransformHandles();
return true;
}
function confirmTransform() {
closeTransformPopup();
const target = state.transformTarget;
if (target === 'selection') {
clearTransformState();
composite();
syncSelectionUi?.();
schedulePersist?.();
uiModule?.showToast('Selection transformed');
return;
}
for (const snapshot of state.transformItems) {
const layer = snapshot.layer;
if (!layer || !['text', 'shape'].includes(layer.kind)) continue;
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const center = { x: offset.x + layer.canvas.width / 2, y: offset.y + layer.canvas.height / 2 };
const retained = layer.kind === 'text' ? normalizeTextData(layer.text) : normalizeShapeData(layer.shape);
retained.transform.scaleX *= state.transformPendingW / Math.max(1, state.transformSessionStartW);
retained.transform.scaleY *= state.transformPendingH / Math.max(1, state.transformSessionStartH);
retained.transform.rotation += state.transformPendingRot;
if (state.transformPendingFlipH) retained.transform.flipH = !retained.transform.flipH;
if (state.transformPendingFlipV) retained.transform.flipV = !retained.transform.flipV;
if (layer.kind === 'text') {
layer.text = retained;
renderTextLayer(layer);
} else {
layer.shape = retained;
renderShapeLayer(layer);
}
state.layerOffsets.set(layer.id, {
x: Math.round(center.x - layer.canvas.width / 2),
y: Math.round(center.y - layer.canvas.height / 2),
});
}
const count = state.transformItems.length;
clearTransformState();
composite();
syncGeometryControls?.();
schedulePersist?.();
uiModule.showToast(count > 1 ? `${count} layers transformed` : 'Transform applied');
}
function nudgeTransform(dx, dy) {
if (!state.transformActive || !state.transformCenter) return false;
state.transformCenter = {
x: state.transformCenter.x + Number(dx || 0),
y: state.transformCenter.y + Number(dy || 0),
};
reapplyTransform();
return true;
}
function clearTransformState() {
state.transformOrigCanvas = null;
state.transformOrigMasks = [];
state.transformOrigOffset = null;
state.transformSelectionCanvas = null;
state.transformLayers = [];
state.transformItems = [];
state.transformSelectionBounds = null;
state.transformBounds = null;
state.transformCenter = null;
state.transformStartCenter = null;
state.transformOrigW = 0;
state.transformOrigH = 0;
state.transformPendingW = 0;
state.transformPendingH = 0;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
state.transformActive = false;
state.transformTarget = null;
state.transformLayer = null;
state.transformHandle = null;
composite();
uiModule.showToast('Transform applied');
state.hoveredHandle = null;
state.transformSessionStartW = 0;
state.transformSessionStartH = 0;
state.transformStartRotation = 0;
state.transformStartFlipH = false;
state.transformStartFlipV = false;
lastValidLayerTarget = null;
lastTransformLimitMessage = '';
}
function cancelTransform() {
closeTransformPopup();
state.transformOrigCanvas = null;
state.transformOrigOffset = null;
if (state.transformLayer) undo(); // restore saved state
state.transformActive = false;
state.transformLayer = null;
state.transformHandle = null;
const hadTransform = state.transformActive;
if (hadTransform) {
undo(); // restore the one pre-session snapshot
state.redoStack = []; // Cancel must not expose the discarded preview via Redo.
}
clearTransformState();
composite();
syncGeometryControls?.();
syncSelectionUi?.();
}
return {
startTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, confirmTransform, cancelTransform,
startTransform, startSelectionTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
};
}
+9 -4
View File
@@ -18,9 +18,10 @@
* }} deps
*/
import { state } from '../state.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
import { canvasCoords } from '../canvas-coords.js';
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand, deselectSelection }) {
return {
click(e) {
const layer = activeLayer();
@@ -28,14 +29,18 @@ export function createWandTool({ activeLayer, saveState, composite, wandHits, ru
const coords = canvasCoords(e, state.mainCanvas);
// Persistent toggle sets the default mode; Shift forces add, Alt
// forces subtract regardless of the toggle (modifiers always win).
let mode = state.wandMode || 'replace';
if (e.shiftKey) mode = 'add';
else if (e.altKey) mode = 'subtract';
const mode = selectionModeForEvent(e, state.wandMode || 'replace');
// Click INSIDE the existing selection with no modifier → deselect.
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
if (deselectSelection) {
deselectSelection();
return;
}
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandMaskSpace = 'layer';
state.selectionSource = null;
state.wandLastSeed = null;
composite();
return;
@@ -0,0 +1,151 @@
/** Pure geometry shared by transform-frame drawing and hit testing. */
const HANDLE_LAYOUT = [
['tl', -0.5, -0.5],
['t', 0, -0.5],
['tr', 0.5, -0.5],
['r', 0.5, 0],
['br', 0.5, 0.5],
['b', 0, 0.5],
['bl', -0.5, 0.5],
['l', -0.5, 0],
];
function finite(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
export function transformFrameGeometry(frame, options = {}) {
const centerX = finite(frame?.centerX);
const centerY = finite(frame?.centerY);
const width = Math.max(1, Math.abs(finite(frame?.width, 1)));
const height = Math.max(1, Math.abs(finite(frame?.height, 1)));
const rotation = finite(frame?.rotation);
const radians = rotation * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const rotateLocal = (localX, localY) => ({
x: centerX + localX * cos - localY * sin,
y: centerY + localX * sin + localY * cos,
});
const resizeHandles = HANDLE_LAYOUT.map(([id, xFactor, yFactor]) => ({
id,
...rotateLocal(width * xFactor, height * yFactor),
}));
const byId = Object.fromEntries(resizeHandles.map(handle => [handle.id, handle]));
const zoom = Math.max(0.0001, finite(options.zoom, 1));
const rotationOffset = Math.max(0, finite(options.rotationOffsetCss, 24)) / zoom;
const rotationLocalY = options.rotationInside
? -Math.max(0, height / 2 - rotationOffset)
: -(height / 2 + rotationOffset);
const rotationHandle = {
id: 'rot',
...rotateLocal(0, rotationLocalY),
};
return {
centerX,
centerY,
width,
height,
rotation,
radians,
resizeHandles,
handles: [...resizeHandles, rotationHandle],
rotationHandle,
corners: {
tl: { x: byId.tl.x, y: byId.tl.y },
tr: { x: byId.tr.x, y: byId.tr.y },
br: { x: byId.br.x, y: byId.br.y },
bl: { x: byId.bl.x, y: byId.bl.y },
},
pivot: { x: centerX, y: centerY },
};
}
export function hitTestTransformHandle(geometry, x, y, options = {}) {
if (!geometry) return null;
const zoom = Math.max(0.0001, finite(options.zoom, 1));
const pointerType = options.pointerType || 'mouse';
const radiusCss = pointerType === 'touch' || pointerType === 'pen' ? 20 : 8;
const radius = radiusCss / zoom;
const radiusSquared = radius * radius;
for (const handle of geometry.handles || []) {
const dx = finite(x) - handle.x;
const dy = finite(y) - handle.y;
if (dx * dx + dy * dy <= radiusSquared) return handle.id;
}
return null;
}
export function pointInTransformFrame(geometry, x, y) {
if (!geometry) return false;
const dx = finite(x) - geometry.centerX;
const dy = finite(y) - geometry.centerY;
const cos = Math.cos(-geometry.radians);
const sin = Math.sin(-geometry.radians);
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
const epsilon = 0.0001;
return Math.abs(localX) <= geometry.width / 2 + epsilon
&& Math.abs(localY) <= geometry.height / 2 + epsilon;
}
/** Resize a frame from a screen-space pointer delta on its rotated axes. */
export function resizeTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {
const geometry = transformFrameGeometry(frame);
const xSign = handle?.includes('l') ? -1 : handle?.includes('r') ? 1 : 0;
const ySign = handle?.includes('t') ? -1 : handle?.includes('b') ? 1 : 0;
if (!xSign && !ySign) return {
width: geometry.width,
height: geometry.height,
centerX: geometry.centerX,
centerY: geometry.centerY,
flipH: !!options.flipH,
flipV: !!options.flipV,
};
const worldDx = finite(currentPoint?.x) - finite(startPoint?.x);
const worldDy = finite(currentPoint?.y) - finite(startPoint?.y);
const cos = Math.cos(geometry.radians);
const sin = Math.sin(geometry.radians);
const localDx = worldDx * cos + worldDy * sin;
const localDy = -worldDx * sin + worldDy * cos;
const multiplier = options.centered ? 2 : 1;
let rawWidth = geometry.width + xSign * localDx * multiplier;
let rawHeight = geometry.height + ySign * localDy * multiplier;
if (options.lockAspect) {
const aspect = geometry.width / geometry.height;
const widthChange = xSign ? Math.abs(Math.abs(rawWidth) - geometry.width) / geometry.width : -1;
const heightChange = ySign ? Math.abs(Math.abs(rawHeight) - geometry.height) / geometry.height : -1;
if (xSign && (!ySign || widthChange >= heightChange)) {
const sign = rawHeight < 0 ? -1 : 1;
rawHeight = sign * Math.abs(rawWidth) / aspect;
} else if (ySign) {
const sign = rawWidth < 0 ? -1 : 1;
rawWidth = sign * Math.abs(rawHeight) * aspect;
}
}
const width = Math.max(1, Math.round(Math.abs(rawWidth)));
const height = Math.max(1, Math.round(Math.abs(rawHeight)));
const signedWidth = (rawWidth < 0 ? -1 : 1) * width;
const signedHeight = (rawHeight < 0 ? -1 : 1) * height;
const localCenterX = options.centered || !xSign
? 0
: xSign * (signedWidth - geometry.width) / 2;
const localCenterY = options.centered || !ySign
? 0
: ySign * (signedHeight - geometry.height) / 2;
return {
width,
height,
centerX: geometry.centerX + localCenterX * cos - localCenterY * sin,
centerY: geometry.centerY + localCenterX * sin + localCenterY * cos,
flipH: !!options.flipH !== (rawWidth < 0),
flipV: !!options.flipV !== (rawHeight < 0),
};
}
+30 -6
View File
@@ -26,6 +26,11 @@
* @returns {{ handleImportedImage: (img: HTMLImageElement) => void }}
*/
import { state } from './state.js';
import {
createPlacedData,
initialPlacedMatrix,
renderPlacedLayer,
} from './placed-layer.js';
export function wireImport({ container, saveState, createLayer, composite, renderLayerPanel, uiModule }) {
// Hidden <input type="file"> the topbar + File buttons both click.
@@ -35,7 +40,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
importFileInput.style.display = 'none';
container.appendChild(importFileInput);
function handleImportedImage(img) {
function handleImportedImage(img, sourceName = 'Imported image') {
if (!state.editorOpen) return;
saveState('Import image');
// Scale down if larger than canvas.
@@ -46,11 +51,22 @@ export function wireImport({ container, saveState, createLayer, composite, rende
w = Math.round(w * scale);
h = Math.round(h * scale);
}
const layer = createLayer('Imported', state.imgWidth, state.imgHeight);
const source = document.createElement('canvas');
source.width = img.naturalWidth || img.width;
source.height = img.naturalHeight || img.height;
source.getContext('2d').drawImage(img, 0, 0);
const layer = createLayer('Imported', w, h);
// Centre on the canvas.
const ox = Math.round((state.imgWidth - w) / 2);
const oy = Math.round((state.imgHeight - h) / 2);
layer.ctx.drawImage(img, ox, oy, w, h);
layer.kind = 'placed';
layer.placed = createPlacedData(
source,
initialPlacedMatrix(source.width, source.height, { x: ox, y: oy, width: w, height: h }),
sourceName,
);
const rendered = renderPlacedLayer(layer);
state.layerOffsets.set(layer.id, rendered.offset);
state.layers.push(layer);
state.activeLayerId = layer.id;
// Switch to move tool so the imported layer is immediately
@@ -72,7 +88,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => handleImportedImage(img);
img.onload = () => handleImportedImage(img, file.name || 'Imported image');
img.src = ev.target.result;
};
reader.readAsDataURL(file);
@@ -83,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;
@@ -97,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();
}
});
@@ -128,7 +152,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
overlay.remove();
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => handleImportedImage(img);
img.onload = () => handleImportedImage(img, item.name || item.filename || 'Gallery image');
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load gallery image'); };
img.src = item.url;
});
+2 -11
View File
@@ -33,6 +33,7 @@
* }} deps
*/
import { state } from './state.js';
import { invertMaskInPlace } from './mask-utils.js';
const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_OFF_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
@@ -112,17 +113,7 @@ export function wireInpaintControls({
});
document.getElementById('ge-inpaint-invert')?.addEventListener('click', () => {
if (!state.maskCtx || !state.maskCanvas) return;
const imgData = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const alpha = d[i + 3];
if (alpha > 0) {
d[i] = 0; d[i+1] = 0; d[i+2] = 0; d[i+3] = 0;
} else {
d[i] = 255; d[i+1] = 255; d[i+2] = 255; d[i+3] = 255;
}
}
state.maskCtx.putImageData(imgData, 0, 0);
invertMaskInPlace(state.maskCanvas);
composite();
syncToolClearIndicators();
uiModule.showToast('Mask inverted');
+38 -33
View File
@@ -16,10 +16,15 @@
* renderLayerPanel: () => void,
* composite: () => void,
* renderLayer?: (layer) => HTMLCanvasElement,
* flatten?: () => HTMLCanvasElement,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { rasterizeTextLayer } from './text-layer.js';
import { rasterizeShapeLayer } from './shape-layer.js';
import { drawLayerStack, normalizeLayerClipping } from './layer-clipping.js';
import { groupForLayer, normalizeLayerGroups } from './layer-groups.js';
function _renderSource(layer, renderLayer) {
if (!layer) return null;
@@ -30,59 +35,55 @@ function _renderSource(layer, renderLayer) {
}
}
function _clearBakedAdjustments(layer) {
function _markBakedRaster(layer) {
if (!layer) return;
rasterizeTextLayer(layer);
rasterizeShapeLayer(layer);
layer.adjLayers = [];
layer._adjFinal = null;
layer._adjFinalKey = '';
layer._adjCache = null;
layer._adjCacheKey = '';
layer.masks = [];
layer.activeMaskId = null;
layer.clipped = false;
}
export function mergeLayerDownAtIndex(idx, renderLayer = null) {
if (idx < 1 || idx >= state.layers.length) return null;
const upper = state.layers[idx];
const lower = state.layers[idx - 1];
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
const lowerSource = _renderSource(lower, renderLayer);
const upperSource = _renderSource(upper, renderLayer);
if (lower.clipped || groupForLayer(state, upper.id)?.id !== groupForLayer(state, lower.id)?.id) return null;
const merged = document.createElement('canvas');
merged.width = state.imgWidth;
merged.height = state.imgHeight;
const mctx = merged.getContext('2d');
mctx.globalAlpha = lower.opacity;
mctx.drawImage(lowerSource, lowerOff.x, lowerOff.y);
mctx.globalAlpha = upper.opacity;
mctx.drawImage(upperSource, upperOff.x, upperOff.y);
mctx.globalAlpha = 1;
drawLayerStack(mctx, state, [lower, upper], layer => _renderSource(layer, renderLayer));
lower.canvas = merged;
lower.ctx = lower.canvas.getContext('2d');
lower.opacity = 1;
lower.visible = true;
lower.blendMode = 'source-over';
state.layerOffsets.set(lower.id, { x: 0, y: 0 });
_clearBakedAdjustments(lower);
_markBakedRaster(lower);
state.layers.splice(idx, 1);
state.layerOffsets.delete(upper.id);
for (const group of state.layerGroups || []) group.layerIds = group.layerIds.filter(id => id !== upper.id);
normalizeLayerGroups(state);
normalizeLayerClipping(state);
state.activeLayerId = lower.id;
return lower;
}
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, uiModule }) {
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, flatten, uiModule }) {
// Flatten Copy.
document.getElementById('ge-flatten')?.addEventListener('click', () => {
if (state.layers.length < 2) return;
saveState('Flatten copy');
const merged = createLayer('Flattened', state.imgWidth, state.imgHeight);
const ctx = merged.ctx;
for (const l of state.layers) {
if (!l.visible) continue;
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
ctx.globalAlpha = l.opacity;
ctx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
ctx.globalAlpha = 1;
}
_clearBakedAdjustments(merged);
if (typeof flatten === 'function') merged.ctx.drawImage(flatten(), 0, 0);
else drawLayerStack(merged.ctx, state, state.layers.filter(layer => layer.visible), layer => _renderSource(layer, renderLayer));
_markBakedRaster(merged);
state.layers.push(merged);
state.activeLayerId = merged.id;
renderLayerPanel();
@@ -99,29 +100,26 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
}
saveState('Merge all');
const base = visibleLayers[0];
const merged = document.createElement('canvas');
merged.width = state.imgWidth;
merged.height = state.imgHeight;
const baseCtx = merged.getContext('2d');
for (let i = 0; i < visibleLayers.length; i++) {
const l = visibleLayers[i];
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
baseCtx.globalAlpha = l.opacity;
baseCtx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
baseCtx.globalAlpha = 1;
const merged = typeof flatten === 'function' ? flatten() : document.createElement('canvas');
if (typeof flatten !== 'function') {
merged.width = state.imgWidth;
merged.height = state.imgHeight;
drawLayerStack(merged.getContext('2d'), state, visibleLayers, layer => _renderSource(layer, renderLayer));
}
base.canvas = merged;
base.ctx = base.canvas.getContext('2d');
base.opacity = 1;
base.visible = true;
base.blendMode = 'source-over';
state.layerOffsets.set(base.id, { x: 0, y: 0 });
_clearBakedAdjustments(base);
_markBakedRaster(base);
// Free offset entries for the discarded layers; keep base.
for (const l of state.layers) {
if (l === base) continue;
state.layerOffsets.delete(l.id);
}
state.layers = [base];
state.layerGroups = [];
state.activeLayerId = base.id;
renderLayerPanel();
composite();
@@ -132,8 +130,15 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
document.getElementById('ge-merge-down')?.addEventListener('click', () => {
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
if (idx < 1) return; // can't merge the bottom layer
const upper = state.layers[idx];
const lower = state.layers[idx - 1];
if (lower.clipped || groupForLayer(state, upper.id)?.id !== groupForLayer(state, lower.id)?.id) {
uiModule?.showToast?.('Release the lower clipping mask or keep both layers in the same group before merging');
return;
}
saveState('Merge down');
mergeLayerDownAtIndex(idx, renderLayer);
const merged = mergeLayerDownAtIndex(idx, renderLayer);
if (!merged) return;
renderLayerPanel();
composite();
uiModule.showToast('Layer merged down');
+67 -5
View File
@@ -51,7 +51,66 @@ export function wireSelectionControls({
wandClear, wandDeleteSelection, wandCopyToNewLayer, wandToMask,
buildSelectionHintMask, applyImageTool,
uiModule,
toggleQuickMask,
}) {
document.querySelectorAll('.ge-quick-mask-toggle').forEach(button => {
button.addEventListener('click', toggleQuickMask);
});
// ── Rectangle / ellipse marquee ──
document.querySelectorAll('.ge-marquee-shape-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.marqueeShape = btn.dataset.marqueeShape === 'ellipse' ? 'ellipse' : 'rectangle';
document.querySelectorAll('.ge-marquee-shape-btn').forEach(candidate => {
const active = candidate.dataset.marqueeShape === state.marqueeShape;
candidate.classList.toggle('active', active);
candidate.setAttribute('aria-pressed', active ? 'true' : 'false');
});
});
});
const constraint = document.getElementById('ge-marquee-constraint');
const dimensions = document.querySelector('.ge-marquee-dimensions');
const widthInput = document.getElementById('ge-marquee-width');
const heightInput = document.getElementById('ge-marquee-height');
const syncMarqueeConstraint = () => {
state.marqueeConstraint = ['ratio', 'size'].includes(constraint?.value) ? constraint.value : 'free';
if (dimensions) dimensions.hidden = state.marqueeConstraint === 'free';
if (widthInput) widthInput.step = state.marqueeConstraint === 'size' ? '1' : '0.01';
if (heightInput) heightInput.step = state.marqueeConstraint === 'size' ? '1' : '0.01';
if (widthInput) widthInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedWidth : state.marqueeRatioWidth);
if (heightInput) heightInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedHeight : state.marqueeRatioHeight);
};
const commitMarqueeDimensions = () => {
const w = Math.max(1, Number(widthInput?.value) || 1);
const h = Math.max(1, Number(heightInput?.value) || 1);
if (state.marqueeConstraint === 'size') {
state.marqueeFixedWidth = Math.round(w);
state.marqueeFixedHeight = Math.round(h);
} else {
state.marqueeRatioWidth = w;
state.marqueeRatioHeight = h;
}
if (widthInput) widthInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedWidth : state.marqueeRatioWidth);
if (heightInput) heightInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedHeight : state.marqueeRatioHeight);
};
constraint?.addEventListener('change', syncMarqueeConstraint);
widthInput?.addEventListener('change', commitMarqueeDimensions);
heightInput?.addEventListener('change', commitMarqueeDimensions);
document.getElementById('ge-marquee-swap')?.addEventListener('click', () => {
const oldWidth = widthInput?.value;
if (widthInput && heightInput) {
widthInput.value = heightInput.value;
heightInput.value = oldWidth;
commitMarqueeDimensions();
}
});
if (constraint) constraint.value = state.marqueeConstraint || 'free';
syncMarqueeConstraint();
document.getElementById('ge-marquee-clear')?.addEventListener('click', wandClear);
document.getElementById('ge-marquee-invert')?.addEventListener('click', invertSelection);
document.getElementById('ge-marquee-delete')?.addEventListener('click', wandDeleteSelection);
document.getElementById('ge-marquee-copy')?.addEventListener('click', wandCopyToNewLayer);
document.getElementById('ge-marquee-mask')?.addEventListener('click', wandToMask);
// ── Lasso section ──
const lassoFPrev = document.getElementById('ge-lasso-feather-preview');
function syncLassoFeather(v) {
@@ -137,16 +196,19 @@ export function wireSelectionControls({
});
// Toggle the translucent red overlay for the wand selection.
document.getElementById('ge-wand-vis')?.addEventListener('click', () => {
const toggleSelectionOverlay = () => {
state.wandMaskVisible = !state.wandMaskVisible;
const btn = document.getElementById('ge-wand-vis');
if (btn) {
['ge-wand-vis', 'ge-marquee-vis', 'ge-sam-vis'].forEach(id => {
const btn = document.getElementById(id);
if (!btn) return;
btn.innerHTML = state.wandMaskVisible ? EYE_OPEN : EYE_OFF;
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
btn.classList.toggle('visible', state.wandMaskVisible);
}
});
composite();
});
};
document.getElementById('ge-wand-vis')?.addEventListener('click', toggleSelectionOverlay);
document.getElementById('ge-marquee-vis')?.addEventListener('click', toggleSelectionOverlay);
document.getElementById('ge-wand-clear')?.addEventListener('click', wandClear);
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
+172 -36
View File
@@ -21,71 +21,99 @@
* saveState: (label?: string) => void,
* composite: () => void,
* fitZoom: () => void,
* renderLayerPanel: () => void,
* promptCanvasSize: (opts: object) => Promise<{w, h} | null>,
* doFillSelection: () => void,
* rotateAllLayers: (deg: number) => void,
* flipAllLayers: (axis: 'h' | 'v') => void,
* applyGaussianBlur: () => void,
* applyZoomBlur: () => void,
* addRetainedGaussianBlur: () => void,
* addRetainedEffect: (type: string) => void,
* uiModule: object,
* }} deps
*
* @returns {{
* applyResize: (newW: number, newH: number) => void,
* resizeCustomPrompt: () => Promise<void>,
* applyCanvasResize: (newW: number, newH: number) => void,
* applyImageResize: (newW: number, newH: number) => void,
* canvasSizePrompt: () => Promise<void>,
* imageSizePrompt: () => Promise<void>,
* }}
*/
import { state } from './state.js';
import { resizeCanvasDocument, resizeImageDocument } from './document-geometry.js';
export function wireTopbarMenus({
closeOtherTopbarMenus, registerDocClickAway,
saveState, composite, fitZoom,
saveState, composite, fitZoom, renderLayerPanel,
promptCanvasSize, doFillSelection,
rotateAllLayers, flipAllLayers,
applyGaussianBlur, applyZoomBlur,
applyGaussianBlur, applyZoomBlur, addRetainedGaussianBlur, addRetainedEffect,
selectAll, deselectSelection, reselectSelection, invertSelection,
transformSelection, refineSelection,
saveNamedSelection, loadNamedSelection, deleteNamedSelection,
uiModule,
}) {
// ── Resize canvas ──
// Extracted so both the popup presets and the Ctrl+Shift+T shortcut
// can call it.
function applyResize(newW, newH) {
function finishGeometryChange(newW, newH, message) {
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
renderLayerPanel?.();
uiModule.showToast(message);
}
// Canvas Size changes document bounds without resampling layer pixels.
function applyCanvasResize(newW, newH, options = {}) {
if (!newW || !newH || newW < 1 || newH < 1) {
uiModule.showToast('Invalid size');
return;
}
saveState('Resize canvas');
// Only resize the main canvas — layers keep their original size.
// Content outside the new bounds is clipped during composite, not
// destroyed.
if (state.maskCanvas) {
const tmpMask = document.createElement('canvas');
tmpMask.width = state.maskCanvas.width;
tmpMask.height = state.maskCanvas.height;
tmpMask.getContext('2d').drawImage(state.maskCanvas, 0, 0);
state.maskCanvas.width = newW;
state.maskCanvas.height = newH;
state.maskCtx.drawImage(tmpMask, 0, 0);
}
state.imgWidth = newW;
state.imgHeight = newH;
state.mainCanvas.width = newW;
state.mainCanvas.height = newH;
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
uiModule.showToast(`Canvas resized to ${newW}×${newH}`);
const result = resizeCanvasDocument(state, newW, newH, options);
finishGeometryChange(result.width, result.height, `Canvas resized to ${result.width}×${result.height}`);
}
async function resizeCustomPrompt() {
function applyImageResize(newW, newH, options = {}) {
if (!newW || !newH || newW < 1 || newH < 1) {
uiModule.showToast('Invalid size');
return;
}
saveState('Resize image');
const result = resizeImageDocument(state, newW, newH, {
smoothingQuality: options.interpolation || 'high',
});
finishGeometryChange(result.width, result.height, `Image resized to ${result.width}×${result.height}`);
}
async function canvasSizePrompt() {
const result = await promptCanvasSize({
title: 'Canvas size',
okLabel: 'Apply',
initialW: state.imgWidth,
initialH: state.imgHeight,
showUnits: true,
showInterpolation: true,
keepProportions: true,
showAnchor: true,
});
if (!result) return;
applyResize(result.w, result.h);
applyCanvasResize(result.w, result.h, result);
}
async function imageSizePrompt() {
const result = await promptCanvasSize({
title: 'Image size',
okLabel: 'Resample',
initialW: state.imgWidth,
initialH: state.imgHeight,
showUnits: true,
showInterpolation: true,
keepProportions: true,
showPresets: false,
});
if (!result) return;
applyImageResize(result.w, result.h, result);
}
// ── Image menu ──
@@ -104,7 +132,8 @@ export function wireTopbarMenus({
if (!item || item.disabled) return;
menu.hidden = true;
const action = item.dataset.imageAction;
if (action === 'resize') resizeCustomPrompt();
if (action === 'canvas-size') canvasSizePrompt();
else if (action === 'image-size') imageSizePrompt();
else if (action === 'selection') document.getElementById('ge-edge-menu-btn')?.click();
else if (action === 'fill') doFillSelection();
else if (action === 'rotate-90') rotateAllLayers(90);
@@ -118,6 +147,96 @@ export function wireTopbarMenus({
}
}
// ── Select menu ──
const renderSelectionMenu = () => {
const hasSelection = !!state.wandMask || (state.lassoPoints?.length >= 3 && !state.lassoActive);
const menu = document.getElementById('ge-selection-menu');
if (!menu) return;
menu.querySelector('[data-selection-action="deselect"]')?.toggleAttribute('disabled', !hasSelection);
menu.querySelector('[data-selection-action="invert"]')?.toggleAttribute('disabled', !hasSelection);
menu.querySelector('[data-selection-action="transform"]')?.toggleAttribute('disabled', !hasSelection);
menu.querySelector('[data-selection-action="refine"]')?.toggleAttribute('disabled', !hasSelection);
menu.querySelector('[data-selection-action="save"]')?.toggleAttribute('disabled', !hasSelection);
menu.querySelector('[data-selection-action="reselect"]')?.toggleAttribute('disabled', !state.lastSelection?.canvas);
const list = document.getElementById('ge-saved-selection-list');
if (!list) return;
list.innerHTML = '';
for (const saved of state.savedSelections || []) {
const row = document.createElement('div');
row.className = 'ge-saved-selection-row';
const load = document.createElement('button');
load.type = 'button';
load.className = 'ge-saved-selection-load';
load.dataset.selectionAction = 'load';
load.dataset.selectionId = saved.id;
load.textContent = saved.name;
load.title = `Load ${saved.name}`;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'ge-saved-selection-delete';
remove.dataset.selectionAction = 'delete';
remove.dataset.selectionId = saved.id;
remove.textContent = '×';
remove.title = `Delete ${saved.name}`;
remove.setAttribute('aria-label', `Delete ${saved.name}`);
row.append(load, remove);
list.appendChild(row);
}
if (!list.children.length) {
const empty = document.createElement('div');
empty.className = 'ge-saved-selection-empty';
empty.textContent = 'None saved';
list.appendChild(empty);
}
};
{
const btn = document.getElementById('ge-selection-menu-btn');
const menu = document.getElementById('ge-selection-menu');
const nameInput = document.getElementById('ge-selection-name');
if (btn && menu) {
btn.addEventListener('click', event => {
event.stopPropagation();
const willOpen = menu.hidden;
if (willOpen) {
closeOtherTopbarMenus('ge-selection-menu');
renderSelectionMenu();
}
menu.hidden = !menu.hidden;
});
menu.addEventListener('click', event => {
const item = event.target.closest('[data-selection-action]');
if (!item || item.disabled) return;
const action = item.dataset.selectionAction;
let handled = false;
if (action === 'all') handled = selectAll?.();
else if (action === 'deselect') handled = deselectSelection?.();
else if (action === 'reselect') handled = reselectSelection?.();
else if (action === 'invert') handled = invertSelection?.();
else if (action === 'transform') handled = transformSelection?.();
else if (action === 'refine') handled = refineSelection?.();
else if (action === 'save') {
handled = saveNamedSelection?.(nameInput?.value);
if (handled && nameInput) nameInput.value = '';
} else if (action === 'load') handled = loadNamedSelection?.(item.dataset.selectionId);
else if (action === 'delete') handled = deleteNamedSelection?.(item.dataset.selectionId);
if (handled) renderSelectionMenu();
if (handled && !['save', 'delete'].includes(action)) menu.hidden = true;
});
nameInput?.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault();
if (saveNamedSelection?.(nameInput.value)) {
nameInput.value = '';
renderSelectionMenu();
}
});
registerDocClickAway(event => {
if (!menu.hidden && !menu.contains(event.target) && event.target !== btn) menu.hidden = true;
});
}
}
// ── Filter menu (Blur sub-menu — Gaussian / Zoom) ──
{
const btn = document.getElementById('ge-filter-menu-btn');
@@ -134,7 +253,16 @@ export function wireTopbarMenus({
if (!item) return;
menu.hidden = true;
const action = item.dataset.filterAction;
if (action === 'blur-gaussian') applyGaussianBlur();
if (action === 'effect-blur-gaussian') addRetainedGaussianBlur?.();
else if (action === 'effect-sharpen') addRetainedEffect?.('sharpen');
else if (action === 'effect-color-overlay') addRetainedEffect?.('color-overlay');
else if (action === 'effect-drop-shadow') addRetainedEffect?.('drop-shadow');
else if (action === 'effect-stroke') addRetainedEffect?.('stroke');
else if (action === 'effect-preset-soft-blur') addRetainedGaussianBlur?.('soft-blur');
else if (action === 'effect-preset-crisp-detail') addRetainedEffect?.('sharpen', 'crisp-detail');
else if (action === 'effect-preset-soft-shadow') addRetainedEffect?.('drop-shadow', 'soft-shadow');
else if (action === 'effect-preset-white-outline') addRetainedEffect?.('stroke', 'white-outline');
else if (action === 'blur-gaussian') applyGaussianBlur();
else if (action === 'blur-zoom') applyZoomBlur();
});
registerDocClickAway((e) => {
@@ -157,12 +285,12 @@ export function wireTopbarMenus({
menu.querySelectorAll('[data-resize-w]').forEach(item => {
item.addEventListener('click', () => {
menu.hidden = true;
applyResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
applyCanvasResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
});
});
menu.querySelector('[data-resize-custom]')?.addEventListener('click', () => {
menu.hidden = true;
resizeCustomPrompt();
canvasSizePrompt();
});
registerDocClickAway((e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
@@ -170,5 +298,13 @@ export function wireTopbarMenus({
}
}
return { applyResize, resizeCustomPrompt };
return {
applyCanvasResize,
applyImageResize,
canvasSizePrompt,
imageSizePrompt,
renderSelectionMenu,
// Compatibility alias consumed by the existing Ctrl+Shift+T shortcut.
resizeCustomPrompt: canvasSizePrompt,
};
}
+39
View File
@@ -15,7 +15,12 @@
*/
import { state } from './state.js';
let disposeTopbar;
export function wireTopbarOverflow({ container }) {
disposeTopbar?.();
const cleanups = [];
disposeTopbar = () => cleanups.forEach(cleanup => cleanup());
// Canvas-size badge updater (kept simple — it lives in the topbar).
const sizeLabel = document.getElementById('ge-canvas-size');
function updateSizeLabel() {
@@ -31,6 +36,39 @@ export function wireTopbarOverflow({ container }) {
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
].filter(Boolean);
// Native popovers keep the existing DOM/event ownership while escaping
// the toolbar's horizontal scroll clip and the editor's stacking context.
topbar?.querySelectorAll('.dropdown[hidden]').forEach(menu => {
if (!menu.showPopover) return;
const anchor = menu.parentElement.querySelector('button');
if (!anchor) return;
menu.setAttribute('popover', 'manual');
const position = () => {
const rect = anchor.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
menu.style.left = 'auto';
};
const sync = () => {
if (menu.hidden) {
if (menu.matches(':popover-open')) menu.hidePopover();
} else if (menu.isConnected) {
position();
if (!menu.matches(':popover-open')) menu.showPopover();
}
};
const observer = new MutationObserver(sync);
observer.observe(menu, { attributes: true, attributeFilter: ['hidden'] });
topbar.addEventListener('scroll', position, { passive: true });
window.addEventListener('resize', position);
cleanups.push(() => {
observer.disconnect();
topbar.removeEventListener('scroll', position);
window.removeEventListener('resize', position);
if (menu.matches(':popover-open')) menu.hidePopover();
});
});
function syncOverflow() {
if (!topbar) return;
aiGroup.forEach(el => { el.style.display = ''; });
@@ -43,6 +81,7 @@ export function wireTopbarOverflow({ container }) {
if (topbar && window.ResizeObserver) {
const ro = new ResizeObserver(() => syncOverflow());
ro.observe(topbar);
cleanups.push(() => ro.disconnect());
}
// Initial pass after layout settles.
requestAnimationFrame(syncOverflow);
+19 -10
View File
@@ -4,9 +4,10 @@
* coordination (close-others + global outside-click).
*
* #ge-undo / #ge-redo / #ge-history-btn
* #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
@@ -20,6 +21,7 @@
* undo: () => void,
* redo: () => void,
* toggleHistoryPanel: () => void,
* toggleCompare: () => void,
* fitZoom: () => void,
* applyZoom: () => void,
* exportToGallery: () => void,
@@ -35,9 +37,10 @@
* }} deps
*/
import { state } from './state.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
const TOPBAR_MENU_IDS = ['ge-image-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu'];
const TOPBAR_TRIGGER_IDS = ['ge-image-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn'];
const TOPBAR_MENU_IDS = ['ge-view-menu', 'ge-image-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu'];
const TOPBAR_TRIGGER_IDS = ['ge-view-menu-btn', 'ge-image-menu-btn', 'ge-selection-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn'];
/**
* Close every topbar dropdown except an optional "keep open" one.
@@ -54,7 +57,7 @@ export function closeOtherTopbarMenus(keepId) {
export function wireTopbar(deps) {
const {
undo, redo, toggleHistoryPanel,
undo, redo, toggleHistoryPanel, toggleCompare,
fitZoom, applyZoom,
exportToGallery, downloadPNG, saveProject, loadProjectPrompt,
activeLayer, saveState, applyEdgeFeather, composite,
@@ -65,6 +68,7 @@ export function wireTopbar(deps) {
document.getElementById('ge-undo')?.addEventListener('click', undo);
document.getElementById('ge-redo')?.addEventListener('click', redo);
document.getElementById('ge-history-btn')?.addEventListener('click', toggleHistoryPanel);
document.getElementById('ge-compare-btn')?.addEventListener('click', toggleCompare);
// Save dropdown — "Save ▾" toggles a small menu (Save / Save-as /
// Download / Save project / Load project). Inner items keep their
@@ -118,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(); });
@@ -146,14 +153,16 @@ export function wireTopbar(deps) {
});
// Edge popup — Width input + Feather / Delete action buttons.
function applyEdgeAction(hardDelete) {
async function applyEdgeAction(hardDelete) {
const layer = activeLayer();
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
if (!layer || isLayerPixelLocked(state, layer) || isLayerTransparencyLocked(state, layer)) {
uiModule.showToast('Unlock image and transparent pixels before changing edges');
return;
}
const widthInput = document.getElementById('ge-edge-width');
const width = parseInt(widthInput?.value || '8');
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
saveState();
applyEdgeFeather(layer, width, hardDelete);
if (!await applyEdgeFeather(layer, width, hardDelete)) return;
composite();
uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`);
}
+54
View File
@@ -0,0 +1,54 @@
/** View menu wiring for rulers, guides, grid, and snapping preferences. */
import { state } from './state.js';
export function wireViewMenu({ closeOtherTopbarMenus, registerDocClickAway, precisionGuides, composite, schedulePersist }) {
const button = document.getElementById('ge-view-menu-btn');
const menu = document.getElementById('ge-view-menu');
if (!button || !menu) return;
const sync = () => {
const values = { rulers: state.rulersVisible, grid: state.gridVisible, snap: state.snapEnabled, 'snap-grid': state.snapToGrid };
for (const [action, enabled] of Object.entries(values)) {
const item = menu.querySelector(`[data-view-action="${action}"]`);
item?.classList.toggle('active', !!enabled);
item?.setAttribute('aria-checked', enabled ? 'true' : 'false');
}
const size = menu.querySelector('#ge-grid-size');
if (size && document.activeElement !== size) size.value = String(state.gridSize || 16);
const clear = menu.querySelector('[data-view-action="clear-guides"]');
if (clear) clear.disabled = (state.guides?.vertical?.length || 0) + (state.guides?.horizontal?.length || 0) === 0;
};
button.addEventListener('click', event => {
event.stopPropagation();
const opening = menu.hidden;
if (opening) closeOtherTopbarMenus('ge-view-menu');
menu.hidden = !opening;
if (opening) sync();
});
menu.addEventListener('click', event => {
const item = event.target.closest('[data-view-action]');
if (!item || item.disabled) return;
const action = item.dataset.viewAction;
if (action === 'rulers') state.rulersVisible = !state.rulersVisible;
else if (action === 'grid') state.gridVisible = !state.gridVisible;
else if (action === 'snap') state.snapEnabled = !state.snapEnabled;
else if (action === 'snap-grid') state.snapToGrid = !state.snapToGrid;
else if (action === 'clear-guides') precisionGuides.clearGuides();
if (action !== 'clear-guides') {
precisionGuides.syncVisibility();
schedulePersist?.();
}
sync();
});
menu.querySelector('#ge-grid-size')?.addEventListener('input', event => {
event.stopPropagation();
state.gridSize = Math.max(2, Math.min(1000, Math.round(Number(event.target.value) || 16)));
schedulePersist?.();
composite();
});
registerDocClickAway(event => {
if (!menu.hidden && !menu.contains(event.target) && !button.contains(event.target)) menu.hidden = true;
});
sync();
}