fix(editor): stabilize image workflows and editing controls

This commit is contained in:
pewdiepie-archdaemon
2026-10-30 08:46:10 +00:00
parent 8b8fc62449
commit e79c81fbb8
45 changed files with 1928 additions and 267 deletions
+25 -6
View File
@@ -41,6 +41,7 @@
* }} deps
*/
import { state } from './state.js';
import { imageResultGeometry } from './image-result-geometry.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireInpaintButtons({
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
if (!state.editorOpen) return; // user closed mid-decode
try {
saveState('Inpaint result');
// OpenAI returns at one of its allowed sizes (1024²,
// 1024×1536, 1536×1024) which often differs from our
// canvas. Scale to canvas size with smoothing so the
// inpaint blends in regardless of source dims.
// Whole-image edits keep the provider's native frame; only masked
// edits need registration with the source document's coordinates.
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
const fullFrame = data.full_frame === true;
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
const resultLayer = createLayer(layerName,
fullFrame ? geometry.width : state.imgWidth,
fullFrame ? geometry.height : state.imgHeight);
resultLayer.ctx.imageSmoothingEnabled = true;
resultLayer.ctx.imageSmoothingQuality = 'high';
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
if (fullFrame) {
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
state.layerOffsets.set(resultLayer.id, {
x: geometry.offsetX,
y: geometry.offsetY,
});
} else {
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
}
// Snapshot the AI result + hard mask used for this run.
if (!fullFrame) {
const aiSnap = document.createElement('canvas');
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
}
state.layers.push(resultLayer);
state.activeLayerId = resultLayer.id;
state.lastInpaintLayerId = resultLayer.id;
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
}
composite();
renderLayerPanel();
if (fullFrame) {
if (uiModule) uiModule.showToast('Image edit complete', 4500);
return;
}
// Reveal post-generation Feather + Edge Stroke sliders.
// Cap Edge Stroke at ±padPx so the slider can't ask for
// more AI buffer than we generated.
+14 -65
View File
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
label: ((opt && opt.textContent) || '').trim(),
};
}
const inp = document.getElementById('ge-ai-inpaint');
const opt = resolveInpaintModel(inp);
if (!opt) return { endpoint: '', model: '', label: '' };
const raw = opt.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
};
const generation = document.getElementById('ge-ai-model');
if (generation?.value && generation.value !== '__serve_cookbook__') {
const index = generation.value.indexOf('::');
const model = index < 0 ? '' : generation.value.slice(index + 2);
if (model && modelCaps(model, '', '').inpaint) {
return { endpoint: generation.value.slice(0, index), model,
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
}
}
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
}
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
@@ -140,25 +140,7 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false };
}
// Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
@@ -256,9 +238,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</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;
@@ -346,45 +328,11 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
// Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) {
aiCommandSelect._modelsReady = true;
try {
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
}
}
@@ -409,6 +357,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
});
}
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
container.dispatchEvent(new Event('ge-image-models-ready'));
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
+5 -30
View File
@@ -2,11 +2,10 @@
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
* tuner that runs on the most-recent rembg cutout.
*
* rembg-run button: flatten + POST to /api/image/remove-bg with an
* rembg-run button: active layer + POST to /api/image/remove-bg with an
* optional hint_mask if the user has a wand/lasso selection
* active. After the new layer lands, hides every previously-
* visible layer so the cutout reads cleanly, and binds the
* live-tuner to the new layer.
* active. Updates only that layer and binds its live edge tuner;
* other layers and their visibility remain unchanged.
*
* Live edge-cleanup tuner: snapshots the pristine cutout the
* moment it lands; subsequent feather/grow slider tweaks
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
// NB: edge_feather / edge_grow are applied CLIENT-side so the
// sliders can re-tune the cutout without re-running the model.
const btn = document.getElementById('ge-rembg-run');
const before = state.layers.length;
// Snapshot which layers were visible BEFORE the run so we know
// 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 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));
frames++;
}
if (state.layers.length > before) {
const newLayer = state.layers[state.layers.length - 1];
bindRembgLiveTuner(newLayer);
// Auto-hide underlying layers so the user sees just the
// cutout — the eye toggles back on if they re-enable manually.
for (const layer of state.layers) {
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
layer.visible = false;
}
}
composite();
renderLayerPanel();
}
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// Reset sliders so the new cutout starts clean.
const f = document.getElementById('ge-rembg-feather');
const g = document.getElementById('ge-rembg-grow');
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
if (editedLayer) bindRembgLiveTuner(editedLayer);
});
// ── Live edge-cleanup tuner ──
+61 -3
View File
@@ -5,6 +5,9 @@
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
import { updateFooterEditModel } from './ai-models.js';
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
function open(focusId) {
el.hidden = false;
syncModels();
if (focusId) {
const target = document.getElementById(focusId);
if (target) {
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
close();
}
});
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
document.addEventListener('keydown', onKey);
const onKey = (e) => {
if (e.key !== 'Escape' || el.hidden) return;
e.preventDefault();
e.stopImmediatePropagation();
close();
};
window.addEventListener('keydown', onKey, true);
container.appendChild(el);
const status = el.querySelector('#ge-ai-settings-save-status');
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
function applyModels(settings) {
for (const [id, key] of modelFields) {
if (!(key in settings)) continue;
const select = el.querySelector(`#${id}`);
const model = settings[key] || '';
let option = [...select.options].find(option => {
const value = option.value;
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
});
if (!option && model && select.options.length) {
option = document.createElement('option');
option.value = `::${model}`;
option.textContent = `${model} (unavailable)`;
option.disabled = true;
select.append(option);
}
if (option) select.value = option.value;
}
updateFooterEditModel();
}
async function syncModels() {
try { applyModels(await readImagePreferences()); }
catch (error) { status.textContent = error.message; }
}
for (const [id, key] of modelFields) {
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
const raw = event.target.value;
if (raw === '__serve_cookbook__') return;
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
try {
status.textContent = 'Saving…';
await saveImagePreferences({ [key]: model });
status.textContent = 'Saved';
} catch (error) {
await syncModels();
status.textContent = error.message;
}
});
}
const onPreferencesChanged = event => applyModels(event.detail);
window.addEventListener('image-preferences-changed', onPreferencesChanged);
container.addEventListener('ge-image-models-ready', syncModels);
const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val');
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
}
function destroy() {
document.removeEventListener('keydown', onKey);
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
container.removeEventListener('ge-image-models-ready', syncModels);
el.remove();
}
+23 -1
View File
@@ -76,7 +76,12 @@ export function createApplyImageTool({
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
}
const flatCanvas = flatten();
const removeBackground = endpoint === '/api/image/remove-bg';
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
throw new Error('Select an unlocked image layer first');
}
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const body = { image: imageB64, ...extraPayload };
const res = await fetch(endpoint, {
@@ -96,6 +101,23 @@ export function createApplyImageTool({
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
if (removeBackground) {
if (!state.layers.includes(targetLayer)) return;
saveState('Remove background');
// The returned pixels replace the editable source, not just its preview.
targetLayer.kind = 'raster';
targetLayer.placed = null;
targetLayer.text = null;
targetLayer.shape = null;
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer._adjCacheKey = null;
targetLayer._adjFinalKey = null;
composite();
renderLayerPanel();
uiModule?.showToast('Background removed');
return targetLayer;
}
{
saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
+5 -5
View File
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
</div>
<div class="ge-control-row ge-brush-size-row">
<label><span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div>
+35 -5
View File
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
// Only in-progress selection/crop drags are affected by the chrome
// guard. A committed cropRect/selection is a resting state — clicking
// its Apply button must not be treated as "strayed onto chrome".
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
};
state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
// Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a path/rect in the empty
// space around the image. Other tools stay canvas-only.
canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
if (e.target === state.mainCanvas) return; // already handled
// Ignore presses on editor chrome inside the area (the crop Apply panel,
// popups, buttons) so clicking Apply does not start a fresh crop.
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
beginDraw(e);
});
state.mainCanvas.addEventListener('mouseenter', (e) => {
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
let panning = false;
let pid = null;
let transformPointerId = null;
let outsideDrawPointerId = null;
let startX = 0, startY = 0;
let startPanX = 0, startPanY = 0;
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
};
canvasArea.addEventListener('pointerdown', (e) => {
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
// With the crop (or lasso) tool, an empty-area press draws a rect/path
// With a selection or crop tool, an empty-area press draws a rect/path
// rather than panning — so the crop can be pulled out past the image.
// Space-held / middle-mouse (directPan) still pans.
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return;
if (canStartOutside() && !directPan) {
if (e.target === state.mainCanvas || isUiChrome(e) ||
e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
// Mouse starts through mousedown; pen/touch need their own captured path.
if (e.pointerType === 'pen' || e.pointerType === 'touch') {
if (outsideDrawPointerId !== null || e.button !== 0) return;
outsideDrawPointerId = e.pointerId;
suppressMouseUntil = Date.now() + 500;
try { canvasArea.setPointerCapture(e.pointerId); } catch {}
beginDraw(e);
e.preventDefault();
}
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;
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
e.preventDefault();
});
canvasArea.addEventListener('pointermove', (e) => {
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
suppressMouseUntil = Date.now() + 500;
if (!isUiChrome(e)) continueDraw(e);
e.preventDefault();
return;
}
if (transformPointerId !== null && e.pointerId === transformPointerId) {
continueDraw(e);
return;
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
applyOffset(next.x, next.y);
});
const endPan = (e) => {
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
const capturedId = outsideDrawPointerId;
outsideDrawPointerId = null;
suppressMouseUntil = Date.now() + 500;
if (e?.type === 'pointercancel') cancelDraw?.();
else if (e) endDraw(e);
try { canvasArea.releasePointerCapture(capturedId); } catch {}
}
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
const capturedId = transformPointerId;
transformPointerId = null;
+5
View File
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
import { formatCropRect, isGenericIntent } from './nl-edit.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createEditorAgent({
control,
@@ -28,6 +29,7 @@ export function createEditorAgent({
fetchImpl = null,
getSession = null,
getModel = null,
getGenerationModel = null,
getAgentModel = null,
onStep = null,
} = {}) {
@@ -145,6 +147,7 @@ export function createEditorAgent({
activeStep = report;
executed = [];
running = true;
await waitForImagePreferences();
// Honest before-image (captured before any client pre-step) for the
// imperceptible-edit check, then the history mark scoping the undo group.
@@ -194,6 +197,8 @@ export function createEditorAgent({
signal: signal || undefined,
body: JSON.stringify({
prompt, session, editor_session: session, state, screenshot,
_use_image_settings: true,
generation_model: getGenerationModel ? getGenerationModel() : '',
model: (modelInfo && modelInfo.model) || '',
endpoint: (modelInfo && modelInfo.endpoint) || '',
model_label: (modelInfo && modelInfo.label) || '',
+1 -1
View File
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
const contribution = maskedContribution(next, masks[index]);
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
+2 -1
View File
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
}
if (type === 'drop-shadow') {
params.inside = params.inside === true || params.inside === 1;
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));
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
: next;
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
+71
View File
@@ -0,0 +1,71 @@
import { decodeAIImage } from './ai-operation.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createImageGenerationAction({
getDocument, placeResult, getSignal = () => null, onProgress = () => {},
formatError = info => info.detail || 'Image generation failed',
fetchImpl = (...args) => fetch(...args), timeoutMs = 330000,
}) {
return async function generateImage(params = {}) {
const document = getDocument();
if (!document.key) throw new Error('Open a document before generating an image');
const controller = new AbortController();
const parentSignal = getSignal();
const cancel = () => controller.abort();
if (parentSignal?.aborted) cancel();
parentSignal?.addEventListener('abort', cancel, { once: true });
let timedOut = false;
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
const started = Date.now();
const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000);
// A selection can change while the provider works; preserve this request's mask.
let mask = null;
try {
if (params.masked && document.mask) {
mask = globalThis.document.createElement('canvas');
mask.width = document.mask.width; mask.height = document.mask.height;
mask.getContext('2d').drawImage(document.mask, 0, 0);
}
await waitForImagePreferences();
controller.signal.throwIfAborted();
let response;
try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', {
method: 'POST', credentials: 'same-origin', signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '',
width: Math.max(1, Math.round(Number(params.width) || document.width)),
height: Math.max(1, Math.round(Number(params.height) || document.height)) }),
}); } catch (error) {
if (controller.signal.aborted) throw error;
throw new Error(formatError({ network: true, detail: error.message }));
}
let data;
try { data = await response.json(); }
catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); }
if (!response.ok || data.error) throw new Error(formatError({ status: response.status,
detail: data.detail || data.error || `Image server failed (${response.status})` }));
const image = data.image || data.images?.[0];
if (!image) throw new Error('Image generation returned no image');
const decoded = await decodeAIImage(image, controller.signal);
controller.signal.throwIfAborted();
const current = getDocument();
if (current.key !== document.key || current.width !== document.width || current.height !== document.height) {
throw new Error('The document changed during generation. The result was not placed in another document.');
}
const canvas = globalThis.document.createElement('canvas');
canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(decoded, 0, 0);
return placeResult(canvas, 'Generated', mask);
} catch (error) {
if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.');
throw error;
} finally {
clearTimeout(deadline);
clearInterval(progress);
parentSignal?.removeEventListener('abort', cancel);
}
};
}
+11
View File
@@ -0,0 +1,11 @@
export function imageResultGeometry(width, height, canvasWidth, canvasHeight) {
const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight);
if (matchingRatio) {
const scale = Math.max(canvasWidth / width, canvasHeight / height);
const drawWidth = width * scale, drawHeight = height * scale;
return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0,
drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight };
}
return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2),
drawX: 0, drawY: 0, drawWidth: width, drawHeight: height };
}
+12 -5
View File
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
lassoDeleteSelection, lassoCopyToLayer,
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
return;
}
// Save shortcuts — match the hints shown in the Save dropdown.
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('hue-saturation');
return;
}
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
e.preventDefault();
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
addEmptyLayer();
return;
}
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
// dialog, so click it instead of duplicating the adjustment here.
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
if (e.altKey && e.code === 'KeyL') {
const row = document.querySelector('[data-image-action="adj-levels"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('levels');
return;
}
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects.
+4
View File
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers',
excludeSelector: '.ge-adj-sub-item',
threshold: 4,
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
onReorder: (orderedItems) => {
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
state.layers = newLayers;
normalizeLayerClipping(state);
composite();
// Effects, adjustments and masks are sibling rows, not children
// of the dragged row. Rebuild them under their reordered owner.
render();
}
},
});
+8 -1
View File
@@ -6,7 +6,7 @@ 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-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
'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, '%')] },
@@ -32,6 +32,13 @@ function surface(width, height) {
}
export function renderDropShadow(source, params) {
if (params.inside) {
return renderLayerStyle(source, 'inner-shadow', {
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
distance: Math.hypot(params.x, params.y),
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
});
}
// 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);
+13
View File
@@ -0,0 +1,13 @@
export function finishQuickEditJob(job, result) {
if (job.cancelled || result?.cancelled) {
job.cancelled = true;
job.status = 'cancelled';
job.detail = 'Cancelled';
} else if (job.status === 'error' || result?.ok === false) {
job.status = 'error';
job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed';
} else {
job.status = 'done';
job.detail = result?.message || 'Done';
}
}
+32 -10
View File
@@ -14,19 +14,26 @@ function toolSettings(tool) {
let _lastColor = null;
let _lastTransparentColor = null;
function transparentColor(color) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = color;
function transparentColor(color, alpha = 0) {
const key = `${color}|${alpha}`;
if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = key;
_lastTransparentColor = colorWithAlpha(color, alpha);
return _lastTransparentColor;
}
function colorWithAlpha(color, alpha) {
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));
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
return _lastTransparentColor;
return `rgba(${rgb.join(',')},${alpha})`;
}
const match = color.match(/rgba?\(([^)]+)\)/);
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
return _lastTransparentColor;
if (!match) return `rgba(0,0,0,${alpha})`;
const components = match[1].split(',');
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
}
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
@@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
ctx.fillStyle = color;
} else {
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColor(color));
if (softness > 1) {
// Above the old maximum, remove the firm core with a progressively
// gentler alpha profile while keeping the selected brush diameter.
ctx.fillStyle = color;
const normalized = ctx.fillStyle;
for (let step = 0; step <= 16; step++) {
const t = step / 16;
gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness));
}
} else {
gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColor(color));
}
ctx.fillStyle = gradient;
}
ctx.beginPath();
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
if (!PAINT_TOOLS.has(tool)) return;
const layer = activeLayer();
if (!layer) return;
// Pixel writes invalidate adjusted previews even when adjustment settings
// themselves have not changed (otherwise retouch strokes appear late).
layer._adjFinalKey = null;
layer._adjCacheKey = null;
if (tool === 'clone') return cloneStamp(sample, layer);
if (tool === 'heal') {
// A healing stroke without a picked source uses the convenient local
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
let operation = 'source-over';
let color = state.color;
let alpha = opacity * flow;
let softness = Math.max(0, Math.min(1, settings.softness / 300));
let softness = Math.max(0, Math.min(4, 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';
+5 -2
View File
@@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) {
// softness rises (the CSS already sets the radial gradient — we
// just tween the inner solid radius to communicate the falloff).
if (softPrev) {
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
const amount = state[fields.softness];
const innerStop = Math.max(0, 60 - amount * 0.55);
softPrev.style.background = amount > 300
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
}
});
}
+18 -4
View File
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
menu.classList.remove('ge-flyout-open');
}
});
function positionPanel(parent) {
const panel = parent.nextElementSibling;
if (!panel?.classList.contains('ge-submenu')) return;
const row = parent.getBoundingClientRect();
const origin = (panel.offsetParent || menu).getBoundingClientRect();
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
const left = row.right + panel.offsetWidth > window.innerWidth - 8
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
}
for (const eventName of ['pointerover', 'focusin']) {
menu.addEventListener(eventName, (event) => {
const parent = event.target.closest('.ge-menu-parent');
if (parent) positionPanel(parent);
});
}
menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent');
if (!parent) return;
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
parent.setAttribute('aria-expanded', String(willOpen));
if (willOpen) {
// Align the flyout with the clicked row rather than the menu top.
const panel = parent.nextElementSibling;
if (panel && panel.classList.contains('ge-submenu')) {
panel.style.top = parent.offsetTop + 'px';
}
positionPanel(parent);
}
});
}
+2
View File
@@ -37,6 +37,7 @@
* }} deps
*/
import { state } from './state.js';
import { topPortalZ } from '../toolWindowZOrder.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
};
const positionSaveMenu = () => {
const r = saveBtn.getBoundingClientRect();
saveMenu.style.zIndex = String(topPortalZ());
saveMenu.style.top = `${r.bottom + 2}px`;
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
saveMenu.style.left = 'auto';