mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
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:
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -27,11 +27,18 @@
|
||||
import { state } from './state.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export function resolveInpaintModel(select) {
|
||||
const options = Array.from(select?.options || []).filter(option =>
|
||||
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
||||
const explicit = options.find(option => option.value === select.value);
|
||||
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
|
||||
}
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
// - inpaint: image+mask edit (inpaint / img2img)
|
||||
// Some models do only one (e.g. dall-e-3 = gen-only, no edits API).
|
||||
function modelCaps(modelId, endpointName, endpointType) {
|
||||
export function modelCaps(modelId, endpointName, endpointType) {
|
||||
const id = (modelId || '').toLowerCase();
|
||||
const name = (endpointName || '').toLowerCase();
|
||||
const type = (endpointType || '').toLowerCase();
|
||||
@@ -41,7 +48,9 @@ function modelCaps(modelId, endpointName, endpointType) {
|
||||
// OpenAI image family.
|
||||
if (/dall-e-3/.test(id)) return { gen: true, inpaint: false };
|
||||
if (/dall-e-2/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/gpt-image/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/(?:^|\/)(?:gpt-image[^/]*|gpt-[^/]*-image[^/]*|gemini-[^/]*image[^/]*|qwen-image[^/]*)$/.test(id)) {
|
||||
return { gen: true, inpaint: true };
|
||||
}
|
||||
// Diffusion families — most generic SD/SDXL/Flux base models
|
||||
// support both via diffusers.
|
||||
if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) {
|
||||
@@ -95,13 +104,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const prevGenValue = aiGenSelect?.value || '';
|
||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||
const endpoints = await res.json();
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
let firstInpaint = null;
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
for (const ep of endpoints) {
|
||||
@@ -137,10 +146,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||
// Prefer dedicated inpaint/edit models for default selection.
|
||||
if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) {
|
||||
firstInpaint = value;
|
||||
}
|
||||
}
|
||||
// Per-tool selectors get every img2img-capable entry. Both
|
||||
// caps.inpaint AND caps.gen models work for harmonize /
|
||||
@@ -165,7 +170,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
if (aiInpaintSelect) {
|
||||
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
|
||||
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
|
||||
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
|
||||
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
||||
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
||||
}
|
||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||
// bottom of every model dropdown.
|
||||
@@ -221,7 +227,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
// Fetch failed — still give the user the affordance to set up
|
||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||
// hint about what to do next.
|
||||
const fallback = '<option value="">Auto</option><option value="" disabled>──────────</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Capture repeat clicks before tool handlers can start another request.
|
||||
export function beginAIOperation(button, onCancel) {
|
||||
const controller = new AbortController();
|
||||
const title = button.title;
|
||||
button.disabled = false;
|
||||
button.title = 'Cancel running action';
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
const cancel = event => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
button.addEventListener('click', cancel, true);
|
||||
return {
|
||||
signal: controller.signal,
|
||||
finish() {
|
||||
button.removeEventListener('click', cancel, true);
|
||||
button.title = title;
|
||||
button.removeAttribute('aria-busy');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeAIImage(base64, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const cleanup = () => {
|
||||
image.onload = image.onerror = null;
|
||||
signal.removeEventListener('abort', abort);
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(new DOMException('Cancelled', 'AbortError'));
|
||||
};
|
||||
if (signal.aborted) { abort(); return; }
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.onload = () => { cleanup(); resolve(image); };
|
||||
image.onerror = () => { cleanup(); reject(new Error('Failed to decode result image')); };
|
||||
image.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 & 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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Vendored
+284
-5
@@ -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">⇤</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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">−</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">−</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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
Vendored
+333
@@ -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];
|
||||
@@ -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">×</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();
|
||||
});
|
||||
}
|
||||
@@ -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">−</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 = () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>',
|
||||
};
|
||||
|
||||
|
||||
|
||||
+1618
-68
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LAYER_STYLES } from './layer-styles.js';
|
||||
|
||||
const items = [
|
||||
['bevel-emboss', 'Bevel & Emboss'], ['stroke', 'Stroke'],
|
||||
['inner-shadow', 'Inner Shadow'], ['inner-glow', 'Inner Glow'], ['satin', 'Satin'],
|
||||
['color-overlay', 'Color Overlay'], ['gradient-overlay', 'Gradient Overlay'],
|
||||
['pattern-overlay', 'Pattern Overlay'], ['outer-glow', 'Outer Glow'], ['drop-shadow', 'Drop Shadow'],
|
||||
];
|
||||
let menu = null, activeAnchor = null;
|
||||
export function openLayerStyleMenu(anchor, onSelect) {
|
||||
const wasOpen = activeAnchor === anchor;
|
||||
menu?.remove(); menu = null;
|
||||
activeAnchor?.setAttribute('aria-expanded', 'false'); activeAnchor = null;
|
||||
if (wasOpen) return;
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'dropdown session-dropdown-menu ge-layer-style-menu';
|
||||
popup.setAttribute('popover', 'auto'); popup.setAttribute('role', 'menu');
|
||||
popup.setAttribute('aria-label', 'Layer styles');
|
||||
for (const [type, label] of items) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'dropdown-item-compact';
|
||||
button.setAttribute('role', 'menuitem'); button.textContent = LAYER_STYLES[type]?.label || label;
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation(); popup.hidePopover(); onSelect(type);
|
||||
});
|
||||
popup.append(button);
|
||||
}
|
||||
popup.addEventListener('toggle', () => {
|
||||
if (!popup.matches(':popover-open')) {
|
||||
if (menu === popup) { menu = null; activeAnchor = null; }
|
||||
anchor.setAttribute('aria-expanded', 'false'); popup.remove();
|
||||
}
|
||||
});
|
||||
document.body.append(popup); menu = popup; activeAnchor = anchor;
|
||||
popup.showPopover(); anchor.setAttribute('aria-expanded', 'true');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
popup.style.maxHeight = `${Math.max(40, Math.max(rect.top - 10, innerHeight - rect.bottom - 10))}px`;
|
||||
const bounds = popup.getBoundingClientRect();
|
||||
popup.style.left = `${Math.max(6, Math.min(innerWidth - bounds.width - 6, rect.right - bounds.width))}px`;
|
||||
const top = rect.bottom + bounds.height + 10 <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4;
|
||||
popup.style.top = `${Math.max(6, Math.min(innerHeight - bounds.height - 6, top))}px`;
|
||||
popup.querySelector('button')?.focus();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Layer-style definitions and canvas rendering shared with the effects worker. */
|
||||
const color = (key, label, value) => ({ key, label, type: 'color', value });
|
||||
const number = (key, label, value, min, max, suffix = 'px') => ({ key, label, value, min, max, step: 1, suffix });
|
||||
const opacity = () => number('opacity', 'Opacity', 65, 0, 100, '%');
|
||||
const size = () => number('size', 'Size', 8, 0, 100);
|
||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||
export const LAYER_STYLES = {
|
||||
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
|
||||
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||
'pattern-overlay': { label: 'Pattern Overlay', controls: [color('color', 'Color', '#ffffff'), color('endColor', 'Background', '#222222'), opacity(), number('scale', 'Tile size', 16, 2, 128)] },
|
||||
'outer-glow': { label: 'Outer Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||
};
|
||||
|
||||
export function styleParams(type, values = {}) {
|
||||
return Object.fromEntries(LAYER_STYLES[type].controls.map(control => {
|
||||
const raw = values[control.key] ?? control.value;
|
||||
const value = control.type === 'color'
|
||||
? (/^#[0-9a-f]{6}$/i.test(raw) ? raw : control.value)
|
||||
: Math.max(control.min, Math.min(control.max, Number.isFinite(Number(raw)) ? Number(raw) : control.value));
|
||||
return [control.key, value];
|
||||
}));
|
||||
}
|
||||
|
||||
function surface(width, height) {
|
||||
if (typeof document === 'undefined') return new OffscreenCanvas(width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width; canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function renderDropShadow(source, params) {
|
||||
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
||||
// this silhouette; compositing the source again thickens translucent edges.
|
||||
const silhouette = surface(source.width, source.height);
|
||||
const sc = silhouette.getContext('2d');
|
||||
sc.drawImage(source, 0, 0);
|
||||
sc.globalCompositeOperation = 'source-in';
|
||||
sc.fillStyle = params.color;
|
||||
sc.fillRect(0, 0, source.width, source.height);
|
||||
const shadow = surface(source.width, source.height), ctx = shadow.getContext('2d');
|
||||
ctx.globalAlpha = params.opacity;
|
||||
ctx.filter = `blur(${params.blur / 2}px)`;
|
||||
ctx.drawImage(silhouette, params.x, params.y);
|
||||
return shadow;
|
||||
}
|
||||
|
||||
// Produce only the effect's contribution; the caller composites it over source.
|
||||
export function renderLayerStyle(source, type, values) {
|
||||
const p = styleParams(type, values), w = source.width, h = source.height;
|
||||
const out = surface(w, h), ctx = out.getContext('2d');
|
||||
const rad = (p.angle || 0) * Math.PI / 180;
|
||||
const dx = Math.cos(rad) * (p.distance || 0), dy = -Math.sin(rad) * (p.distance || 0);
|
||||
const clip = () => {
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
};
|
||||
if (type === 'gradient-overlay' || type === 'pattern-overlay') {
|
||||
if (type === 'gradient-overlay') {
|
||||
const length = (Math.abs(Math.cos(rad)) * w + Math.abs(Math.sin(rad)) * h) * p.scale / 200;
|
||||
const gradient = ctx.createLinearGradient(w / 2 - Math.cos(rad) * length, h / 2 + Math.sin(rad) * length, w / 2 + Math.cos(rad) * length, h / 2 - Math.sin(rad) * length);
|
||||
gradient.addColorStop(0, p.color); gradient.addColorStop(1, p.endColor);
|
||||
ctx.fillStyle = gradient;
|
||||
} else {
|
||||
const tile = surface(p.scale * 2, p.scale * 2), tc = tile.getContext('2d');
|
||||
tc.fillStyle = p.endColor; tc.fillRect(0, 0, tile.width, tile.height);
|
||||
tc.fillStyle = p.color; tc.fillRect(0, 0, p.scale, p.scale); tc.fillRect(p.scale, p.scale, p.scale, p.scale);
|
||||
ctx.fillStyle = ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
ctx.fillRect(0, 0, w, h); clip();
|
||||
} else if (type === 'outer-glow') {
|
||||
ctx.filter = `blur(${p.size}px)`;
|
||||
ctx.drawImage(source, 0, 0); ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'destination-out'; ctx.drawImage(source, 0, 0);
|
||||
} else if (type === 'inner-shadow' || type === 'inner-glow') {
|
||||
// Pad the inverse alpha so edges touching the canvas still cast inward.
|
||||
const pad = Math.ceil(p.size * 3 + (p.distance || 0) + 2);
|
||||
const inverse = surface(w + pad * 2, h + pad * 2), ic = inverse.getContext('2d');
|
||||
ic.fillStyle = p.color; ic.fillRect(0, 0, inverse.width, inverse.height);
|
||||
ic.globalCompositeOperation = 'destination-out'; ic.drawImage(source, pad, pad);
|
||||
ctx.filter = `blur(${p.size}px)`;
|
||||
ctx.drawImage(inverse, -pad + dx, -pad + dy); ctx.filter = 'none'; clip();
|
||||
} else if (type === 'satin') {
|
||||
const a = surface(w, h), ac = a.getContext('2d');
|
||||
ac.filter = `blur(${p.size}px)`; ac.drawImage(source, dx, dy);
|
||||
ac.globalCompositeOperation = 'xor'; ac.drawImage(source, -dx, -dy); ac.filter = 'none';
|
||||
ctx.drawImage(a, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h); clip();
|
||||
} else if (type === 'bevel-emboss') {
|
||||
const blurred = surface(w, h), bc = blurred.getContext('2d');
|
||||
bc.filter = `blur(${Math.max(0.5, p.size / 2)}px)`; bc.drawImage(source, 0, 0);
|
||||
const alpha = bc.getImageData(0, 0, w, h).data;
|
||||
const original = source.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
const pixels = ctx.createImageData(w, h), step = Math.max(1, Math.round(p.size / 2));
|
||||
const sample = (x, y) => x < 0 || x >= w || y < 0 || y >= h ? 0 : alpha[(y * w + x) * 4 + 3] / 255;
|
||||
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||||
const light = rgb(p.highlight), dark = rgb(p.color);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const at = (y * w + x) * 4;
|
||||
const slope = ((sample(x + step, y) - sample(x - step, y)) * Math.cos(rad)
|
||||
- (sample(x, y + step) - sample(x, y - step)) * Math.sin(rad)) * p.depth / 100;
|
||||
pixels.data.set(slope > 0 ? light : dark, at);
|
||||
pixels.data[at + 3] = Math.min(1, Math.abs(slope)) * original[at + 3];
|
||||
}
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
const faded = surface(w, h), fc = faded.getContext('2d');
|
||||
fc.globalAlpha = p.opacity / 100; fc.drawImage(out, 0, 0);
|
||||
return faded;
|
||||
}
|
||||
@@ -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 +
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user