mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6741 from the 7011 integration
This commit is contained in:
@@ -6929,13 +6929,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Smooth viewport resize when keyboard opens/closes
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', () => {
|
||||
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
|
||||
});
|
||||
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
// If the browser discarded and restored this tab, reload the current session
|
||||
|
||||
+33
-1
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
onReorder: options.onReorder || null,
|
||||
handleSelector: options.handleSelector || null,
|
||||
excludeSelector: options.excludeSelector || null,
|
||||
ignoreSelector: options.ignoreSelector || null,
|
||||
// When > 0 the pointer must travel this many pixels before the drag
|
||||
// starts, so rows that also handle plain clicks stay clickable.
|
||||
threshold: Math.max(0, Number(options.threshold) || 0),
|
||||
storageKey: options.storageKey || null,
|
||||
};
|
||||
|
||||
let draggedEl = null;
|
||||
let pending = null;
|
||||
let placeholder = null;
|
||||
let offsetY = 0;
|
||||
let items = [];
|
||||
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
draggedEl.style.top = snapTop + 'px';
|
||||
|
||||
setTimeout(() => {
|
||||
// The list may have been re-rendered mid-drag (e.g. dropping a layer
|
||||
// effect copies it and rebuilds the panel), detaching both nodes.
|
||||
if (!placeholder.parentNode || !draggedEl.isConnected) {
|
||||
placeholder.remove();
|
||||
return;
|
||||
}
|
||||
placeholder.parentNode.insertBefore(draggedEl, placeholder);
|
||||
placeholder.remove();
|
||||
draggedEl.classList.remove('dragging');
|
||||
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
function onMouseDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
|
||||
if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return;
|
||||
const item = e.target.closest(itemSelector);
|
||||
if (!item || !container.contains(item)) return;
|
||||
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
|
||||
|
||||
if (config.threshold > 0) {
|
||||
// Defer the lift until the pointer actually moves, so a plain click
|
||||
// (select layer, open the rename field) is left untouched.
|
||||
pending = { item, startY: e.clientY };
|
||||
e.preventDefault();
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startDrag(e.clientY, item);
|
||||
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseMove(e) { moveDrag(e.clientY); }
|
||||
function onMouseMove(e) {
|
||||
if (pending) {
|
||||
if (Math.abs(e.clientY - pending.startY) < config.threshold) return;
|
||||
const { item, startY } = pending;
|
||||
pending = null;
|
||||
e.preventDefault();
|
||||
startDrag(startY, item);
|
||||
}
|
||||
moveDrag(e.clientY);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
pending = null;
|
||||
endDrag();
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,10 @@ export function wireAIToolsMisc({
|
||||
function addEmptyLayer() {
|
||||
saveState('Add layer');
|
||||
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
|
||||
state.layers.push(layer);
|
||||
// Photoshop inserts the new layer directly above the selected one.
|
||||
const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);
|
||||
const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;
|
||||
state.layers.splice(insertIndex, 0, layer);
|
||||
state.activeLayerId = layer.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
|
||||
Vendored
+301
-171
@@ -19,6 +19,28 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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 id="ge-liquify-section" style="display:none;">
|
||||
<div class="ge-section-title">Liquify</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-liquify-mode">Mode</label>
|
||||
<select id="ge-liquify-mode" class="ge-ctl-select">
|
||||
<option value="push">Push</option>
|
||||
<option value="twirl">Twirl</option>
|
||||
<option value="pucker">Pucker</option>
|
||||
<option value="bloat">Bloat</option>
|
||||
<option value="smooth">Smooth</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label><span id="ge-liquify-size-label">120px</span></label>
|
||||
<input type="range" id="ge-liquify-size" min="10" max="600" value="120" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Strength <span id="ge-liquify-strength-label">50%</span></label>
|
||||
<input type="range" id="ge-liquify-strength" min="1" max="100" value="50" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-liquify-hint">Drag on the canvas to warp pixels. One undo step per stroke.</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">
|
||||
@@ -33,31 +55,105 @@ 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">
|
||||
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
|
||||
<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}" />
|
||||
<span class="ge-brush-more-wrap">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-brush-more-btn" id="ge-brush-more" aria-haspopup="true" aria-expanded="false" title="More brush options (spacing, smoothing, blend, opacity, pen pressure)" aria-label="More brush options">
|
||||
<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="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="ge-brush-more-menu" id="ge-brush-more-menu" hidden>
|
||||
<div class="ge-section-title">Stroke</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-spacing" aria-hidden="true"></span>
|
||||
<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">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-smoothing" aria-hidden="true"></span>
|
||||
<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-brush-stroke-group" data-stroke-tool="brush">
|
||||
<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-opacity-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>
|
||||
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-flow-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
|
||||
<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>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-brush-stroke-group" data-stroke-tool="clone">
|
||||
<div class="ge-section-title" id="ge-stroke-title-clone">Clone</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>
|
||||
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-brush-stroke-group" data-stroke-tool="smudge">
|
||||
<div class="ge-section-title">Smudge</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row">
|
||||
<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>
|
||||
<div class="ge-control-row ge-brush-pressure-row">
|
||||
<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>
|
||||
</span>
|
||||
</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 class="ge-control-row ge-eraser-row" id="ge-dodgeburn-intensity-row" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-dodgeburn-preview" aria-hidden="true"></span>
|
||||
<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>
|
||||
<input type="range" id="ge-dodgeburn-intensity" min="0" max="100" value="25" title="How strongly Dodge lightens or Burn darkens each dab." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-preset-row">
|
||||
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
||||
@@ -113,11 +209,14 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '1:1' ? ' active' : ''}" data-crop-preset="1:1" title="Square (1:1)">1:1</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '4:5' ? ' active' : ''}" data-crop-preset="4:5" title="Portrait (4:5)">4:5</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '16:9' ? ' active' : ''}" data-crop-preset="16:9" title="Widescreen (16:9)">16:9</button>
|
||||
<label class="ge-crop-custom" title="Type any ratio, e.g. 3:2 or 21:9">
|
||||
<input type="text" id="ge-crop-custom-ratio" inputmode="numeric" placeholder="W:H" aria-label="Custom crop ratio" spellcheck="false" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="ge-section-hint">Pick a ratio, then drag on the canvas. Press Enter to apply.</p>
|
||||
</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>
|
||||
<textarea id="ge-text-content" rows="2" 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">
|
||||
@@ -127,6 +226,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<option value="Lobster">Lobster</option>
|
||||
<option value="Bebas Neue">Bebas Neue</option>
|
||||
<option value="Roboto Mono">Roboto Mono</option>
|
||||
<option value="Space Grotesk">Space Grotesk</option>
|
||||
<option value="Fraunces">Fraunces</option>
|
||||
<option value="Syne">Syne</option>
|
||||
<option value="Bungee">Bungee</option>
|
||||
<option value="Instrument Serif">Instrument Serif</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
</select>
|
||||
@@ -144,6 +248,7 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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-opt-more" id="ge-text-more" hidden>
|
||||
<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" />
|
||||
@@ -200,6 +305,8 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label class="ge-text-field ge-text-inline-check"><span>Kerning</span><input type="checkbox" id="ge-text-kerning" checked /></label>
|
||||
<label class="ge-text-field ge-text-inline-check"><span>Ligatures</span><input type="checkbox" id="ge-text-ligatures" checked /></label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle" aria-expanded="false" aria-controls="ge-text-more">More</button>
|
||||
<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;">
|
||||
@@ -214,6 +321,7 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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-opt-more" id="ge-shape-more" hidden>
|
||||
<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>
|
||||
@@ -234,6 +342,8 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-shape-more-toggle" aria-expanded="false" aria-controls="ge-shape-more">More</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-path-section" id="ge-path-section" style="display:none;">
|
||||
@@ -264,14 +374,13 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></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>
|
||||
@@ -281,16 +390,18 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;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>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-marquee-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection"><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>Invert</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels"><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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask"><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="12" cy="12" r="5"/></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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||
@@ -298,61 +409,62 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<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>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></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>
|
||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
|
||||
<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" style="margin-top:4px;">
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
|
||||
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
|
||||
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
|
||||
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
|
||||
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
|
||||
</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+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
|
||||
<span class="ge-lasso-more-wrap">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-lasso-more-btn" id="ge-lasso-more" aria-haspopup="true" aria-expanded="false" aria-label="More lasso options (feather, edge stroke, magnetic)" title="More lasso options (feather, edge stroke, magnetic)">
|
||||
<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="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
|
||||
<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>
|
||||
Delete
|
||||
<div class="ge-lasso-more-menu" id="ge-lasso-more-menu" hidden>
|
||||
<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>
|
||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
|
||||
<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">
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
|
||||
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
|
||||
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
|
||||
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
|
||||
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
|
||||
</div>
|
||||
<p class="ge-lasso-tip">Draw a freehand selection. Esc to cancel.</p>
|
||||
</div>
|
||||
</span>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-lasso-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-lasso-copy" title="Copy selection to new layer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
<button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint mask">
|
||||
<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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)"><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>Invert</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Erase selected pixels"><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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to a 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Add selection to mask"><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="12" cy="12" r="5"/></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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>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>
|
||||
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||
<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>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
||||
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
|
||||
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
|
||||
@@ -365,41 +477,38 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
|
||||
<input type="range" id="ge-wand-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;">
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-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-wand-clear" title="Clear the selection">
|
||||
<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 class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
|
||||
<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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Erase selected pixels">
|
||||
<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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<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="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint mask">
|
||||
<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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to mask">
|
||||
<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="12" cy="12" r="5"/></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>
|
||||
<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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>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>
|
||||
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||
<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>
|
||||
<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"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></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;" />
|
||||
@@ -408,46 +517,120 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<span class="ge-wand-more-wrap">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-sam-more" aria-haspopup="true" aria-expanded="false" aria-label="More SAM options (help)" title="More SAM options (help)"><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="m6 9 6 6 6-6"/></svg></button>
|
||||
<div class="ge-wand-more-menu" id="ge-sam-more-menu" hidden>
|
||||
<p class="ge-wand-tip">Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask. Shift adds, Alt subtracts, Esc clears.</p>
|
||||
</div>
|
||||
</span>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-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-sam-clear" title="Clear the selection">
|
||||
<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 class="ge-btn ge-btn-sm" id="ge-sam-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
|
||||
<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>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-delete" title="Erase selected pixels">
|
||||
<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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-copy" title="Copy selection to a 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to mask">
|
||||
<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="12" cy="12" r="5"/></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>
|
||||
<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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>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>
|
||||
<div class="ge-quickselect-section" id="ge-quickselect-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>QUICK SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Quick Select help" title="Brush over a region to grow the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the brush combines with the current selection. Shift / Alt held during a stroke override this.">
|
||||
<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>
|
||||
<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"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<label style="font-size:10px;opacity:.7;white-space:nowrap;">Brush</label>
|
||||
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" style="flex:1 1 auto;min-width:0;" />
|
||||
<span class="ge-adj-value" id="ge-quickselect-size-value">24</span>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-quickselect-size-preview" aria-hidden="true"></span>
|
||||
<label>Brush <span id="ge-quickselect-size-label">24</span></label>
|
||||
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" />
|
||||
</div>
|
||||
<span class="ge-wand-more-wrap">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-quickselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Quick Select options (help)" title="More Quick Select options (help)"><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="m6 9 6 6 6-6"/></svg></button>
|
||||
<div class="ge-wand-more-menu" id="ge-quickselect-more-menu" hidden>
|
||||
<p class="ge-wand-tip">Paint over a region; the brush grows the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.</p>
|
||||
</div>
|
||||
</span>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-quickselect-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" id="ge-quickselect-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-delete" title="Erase selected pixels">
|
||||
<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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-copy" title="Copy selection to a 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-mask" title="Add selection to mask">
|
||||
<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="12" cy="12" r="5"/></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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Paint over a region; the brush grows the selection to similar nearby pixels.</p>
|
||||
</div>
|
||||
<div class="ge-objectselect-section" id="ge-objectselect-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>OBJECT SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Object Select help" title="Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the object selection combines with the current selection. Shift / Alt held during a stroke override this.">
|
||||
<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>
|
||||
<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"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||
</div>
|
||||
<span class="ge-wand-more-wrap">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-objectselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Object Select options (help)" title="More Object Select options (help)"><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="m6 9 6 6 6-6"/></svg></button>
|
||||
<div class="ge-wand-more-menu" id="ge-objectselect-more-menu" hidden>
|
||||
<p class="ge-wand-tip">Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude. Shift adds, Esc clears.</p>
|
||||
</div>
|
||||
</span>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-objectselect-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" id="ge-objectselect-clear" title="Clear selection" aria-label="Clear selection">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<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>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-delete" title="Erase selected pixels">
|
||||
<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="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-copy" title="Copy selection to a 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-mask" title="Add selection to mask">
|
||||
<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="12" cy="12" r="5"/></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"><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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a rough stroke over an object; SAM selects its boundary.</p>
|
||||
</div>
|
||||
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||
@@ -550,72 +733,19 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
</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>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-clone-sample-mode">Sample</label>
|
||||
<select id="ge-clone-sample-mode">
|
||||
<select id="ge-clone-sample-mode" class="ge-ctl-select">
|
||||
<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>
|
||||
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
|
||||
<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>
|
||||
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
|
||||
<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">
|
||||
<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-eraser-section" id="ge-eraser-section" style="display:none;">
|
||||
<div class="ge-section-title">Eraser</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-transform-section" id="ge-transform-section" style="display:none;"></div>
|
||||
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* }} opts
|
||||
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
|
||||
*/
|
||||
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false } = {}) {
|
||||
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false, onCloseGroup = null } = {}) {
|
||||
const dock = document.createElement('div');
|
||||
dock.className = 'ge-dock';
|
||||
if (compact) dock.classList.add('ge-dock--compact');
|
||||
@@ -218,7 +218,7 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
divider.className = 'ge-dock-divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
divider.setAttribute('aria-orientation', 'horizontal');
|
||||
divider.title = 'Drag to resize panel group';
|
||||
divider.title = 'Drag to resize · double-click to expand/collapse a panel group';
|
||||
dock.appendChild(divider);
|
||||
wireDivider(divider, groupIndex);
|
||||
}
|
||||
@@ -324,7 +324,9 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
// Reset any previous hiding so widths are measured at their natural size.
|
||||
items.forEach(item => { item.style.display = ''; });
|
||||
const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;
|
||||
moreEl.hidden = !overflowing;
|
||||
// Keep the menu reachable even when the tabs fit, so its "Close <group>"
|
||||
// action (and every tab) stays available for every section.
|
||||
moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');
|
||||
if (!overflowing) return;
|
||||
// Photoshop-style tab overflow: rather than clipping a label mid-word
|
||||
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
|
||||
@@ -378,6 +380,22 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
});
|
||||
tabsMenu.appendChild(item);
|
||||
});
|
||||
// Close the whole section (panel group) from the same menu — Photoshop's
|
||||
// "Close Tab Group". Only offered when the host supplies a handler.
|
||||
if (typeof onCloseGroup === 'function') {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-dock-tabs-menu-sep';
|
||||
tabsMenu.appendChild(sep);
|
||||
const closeItem = document.createElement('button');
|
||||
closeItem.type = 'button';
|
||||
closeItem.className = 'ge-dock-tabs-menu-item ge-dock-tabs-menu-close';
|
||||
closeItem.textContent = `Close ${group.name}`;
|
||||
closeItem.addEventListener('click', () => {
|
||||
closeTabsMenu();
|
||||
try { onCloseGroup(group.name); } catch (_) {}
|
||||
});
|
||||
tabsMenu.appendChild(closeItem);
|
||||
}
|
||||
document.body.appendChild(tabsMenu);
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
|
||||
@@ -390,10 +408,12 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
let startY = 0;
|
||||
let startH = 0;
|
||||
let resizing = false;
|
||||
let moved = false;
|
||||
divider.addEventListener('pointerdown', (event) => {
|
||||
const target = groupEl();
|
||||
if (!target) return;
|
||||
resizing = true;
|
||||
moved = false;
|
||||
startY = event.clientY;
|
||||
startH = target.getBoundingClientRect().height;
|
||||
divider.setPointerCapture(event.pointerId);
|
||||
@@ -404,8 +424,14 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
if (!resizing) return;
|
||||
const target = groupEl();
|
||||
if (!target) return;
|
||||
// A real drag supersedes any double-click expand state (a plain click,
|
||||
// which a double-click fires, must not, or the toggle would never fire).
|
||||
if (!moved) { moved = true; target.dataset.dockExpanded = '0'; target.classList.remove('is-expanded'); }
|
||||
const next = Math.max(28, startH + (event.clientY - startY));
|
||||
target.style.flex = `1 1 ${Math.round(next)}px`;
|
||||
// Fixed basis (no grow/shrink): a `1 1` basis lets the height-constrained
|
||||
// dock shrink the dragged group straight back to 0, so the divider looked
|
||||
// dead. Keeping it `0 0` holds the size and lets the dock scroll instead.
|
||||
target.style.flex = `0 0 ${Math.round(next)}px`;
|
||||
target.style.height = `${Math.round(next)}px`;
|
||||
});
|
||||
const end = (event) => {
|
||||
@@ -416,6 +442,35 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
|
||||
};
|
||||
divider.addEventListener('pointerup', end);
|
||||
divider.addEventListener('pointercancel', end);
|
||||
// Double-click toggles the group above between "show everything" and the
|
||||
// previous size. Handy on a cramped sidebar: one double-click unrolls the
|
||||
// whole group (its body is normally height-clamped and scrolls), another
|
||||
// restores exactly what it was.
|
||||
divider.addEventListener('dblclick', (event) => {
|
||||
event.preventDefault();
|
||||
const target = groupEl();
|
||||
if (!target) return;
|
||||
const body = target.querySelector('.ge-dock-panels');
|
||||
if (target.dataset.dockExpanded === '1') {
|
||||
const prev = parseFloat(target.dataset.dockPrevSize || '');
|
||||
const restore = Number.isFinite(prev) && prev > 0 ? prev : 200;
|
||||
target.style.flex = `0 0 ${Math.round(restore)}px`;
|
||||
target.style.height = `${Math.round(restore)}px`;
|
||||
target.dataset.dockExpanded = '0';
|
||||
target.classList.remove('is-expanded');
|
||||
return;
|
||||
}
|
||||
const head = target.querySelector('.ge-dock-tabs');
|
||||
const headH = head ? head.getBoundingClientRect().height : 0;
|
||||
const bodyH = body ? body.scrollHeight : 0;
|
||||
const full = Math.ceil(headH + bodyH + 2);
|
||||
target.dataset.dockPrevSize = String(Math.round(target.getBoundingClientRect().height));
|
||||
target.classList.remove('is-collapsed');
|
||||
target.style.flex = `0 0 ${full}px`;
|
||||
target.style.height = `${full}px`;
|
||||
target.dataset.dockExpanded = '1';
|
||||
target.classList.add('is-expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// Move a panel group out of the dock into a floating, draggable
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
|
||||
* bottom sheet remains the single surface, exactly as before.
|
||||
*/
|
||||
import { TOOLS } from './toolbar.js?v=20261008flyouts';
|
||||
import { TOOLS } from './toolbar.js?v=20261010brushpresets';
|
||||
import { state } from '../state.js';
|
||||
|
||||
// Per-tool option sources. Each value is a CSS selector for an existing
|
||||
@@ -21,23 +21,29 @@ import { state } from '../state.js';
|
||||
const BRUSH_TOOLS = {
|
||||
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
|
||||
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
|
||||
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
|
||||
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
|
||||
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
|
||||
};
|
||||
|
||||
const SELECTION_TOOLS = {
|
||||
marquee: { modes: '#ge-marquee-section', feather: null },
|
||||
lasso: { modes: '#ge-lasso-section', feather: '#ge-lasso-feather' },
|
||||
wand: { modes: '#ge-wand-section', feather: '#ge-wand-feather' },
|
||||
quickselect: { modes: '#ge-quickselect-section', feather: null },
|
||||
objectselect: { modes: '#ge-objectselect-section', feather: null },
|
||||
sam: { modes: '#ge-sam-section', feather: null },
|
||||
marquee: { modes: null, feather: null },
|
||||
lasso: { modes: null, feather: '#ge-lasso-feather' },
|
||||
wand: { modes: null, feather: '#ge-wand-feather' },
|
||||
quickselect: { modes: null, feather: null },
|
||||
objectselect: { modes: null, feather: null },
|
||||
sam: { modes: null, feather: null },
|
||||
};
|
||||
|
||||
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']];
|
||||
// Liquify is not a brush: it exposes its own mode plus dab size and strength.
|
||||
const LIQUIFY_TOOLS = {
|
||||
liquify: { size: '#ge-liquify-size', strength: '#ge-liquify-strength' },
|
||||
};
|
||||
|
||||
const INTERSECT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg>';
|
||||
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', INTERSECT_ICON]];
|
||||
|
||||
function resolve(selector) {
|
||||
if (!selector) return null;
|
||||
@@ -75,7 +81,7 @@ function makeSelect(label, sourceSelector) {
|
||||
function makeAutoSelect() {
|
||||
return `
|
||||
<label class="ge-opt ge-auto-select-option" data-opt="check">
|
||||
<input type="checkbox" id="ge-auto-select-layer" />
|
||||
<input type="checkbox" id="ge-auto-select-layer" class="ge-ctl-check" />
|
||||
<span class="ge-opt-label">Auto-Select</span>
|
||||
</label>
|
||||
<div class="ge-opt" data-opt="select">
|
||||
@@ -103,13 +109,16 @@ function buildToolControls(toolId) {
|
||||
parts.push(makeRange('Opacity', src.opacity));
|
||||
parts.push(makeRange('Flow', src.flow));
|
||||
parts.push(makeRange('Softness', src.softness));
|
||||
if (src.intensity) parts.push(makeRange('Intensity', src.intensity));
|
||||
} else if (SELECTION_TOOLS[toolId]) {
|
||||
const src = SELECTION_TOOLS[toolId];
|
||||
parts.push(makeModes(src.modes));
|
||||
if (src.modes) parts.push(makeModes(src.modes));
|
||||
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
|
||||
} else if (toolId === 'gradient') {
|
||||
parts.push(makeSelect('Type', '#ge-gradient-type'));
|
||||
parts.push(makeRange('Opacity', '#ge-gradient-opacity'));
|
||||
} else if (LIQUIFY_TOOLS[toolId]) {
|
||||
const src = LIQUIFY_TOOLS[toolId];
|
||||
parts.push(makeSelect('Mode', '#ge-liquify-mode'));
|
||||
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
|
||||
parts.push(makeRange('Strength', src.strength));
|
||||
} else if (toolId === 'move') {
|
||||
parts.push(makeAutoSelect());
|
||||
}
|
||||
@@ -157,13 +166,26 @@ export function syncOptionsBar(bar) {
|
||||
export function renderOptionsBar(bar, toolId) {
|
||||
if (!bar) return;
|
||||
bar.dataset.optionsTool = toolId;
|
||||
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
|
||||
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || LIQUIFY_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
|
||||
// Keep the strip mounted at a stable height for every tool. Hiding it
|
||||
// for tools without options reflows the centered canvas-area and makes
|
||||
// the canvas jump vertically when switching tools; an empty strip is
|
||||
// visually harmless.
|
||||
bar.classList.toggle('ge-options-empty', !hasControls);
|
||||
|
||||
// Brush-family tools now expose their secondary rows (spacing / smoothing /
|
||||
// blend / pen pressure) through their own caret dropdown in the strip, so
|
||||
// the generic "..." overflow button is retired for every tool.
|
||||
const moreBtn = bar.querySelector('#ge-options-more');
|
||||
if (moreBtn) {
|
||||
const showMore = false;
|
||||
moreBtn.hidden = !showMore;
|
||||
if (!showMore) {
|
||||
bar.classList.remove('ge-more-open');
|
||||
moreBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
const icon = bar.querySelector('.ge-options-tool-icon');
|
||||
const name = bar.querySelector('.ge-options-tool-name');
|
||||
const meta = TOOLS[toolId];
|
||||
|
||||
@@ -45,7 +45,7 @@ export function shortcutsPopupHTML() {
|
||||
<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>T</kbd> Free Transform</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">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import { state } from '../state.js';
|
||||
import { buildDock } from './dock.js';
|
||||
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'ge-right-panel';
|
||||
// Desktop: present Tool Options + Layers as tabs in a dock. Mobile
|
||||
@@ -236,6 +236,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
||||
// Narrow desktop columns get the compact selector fallback; the
|
||||
// multi-group stack needs a bit more room to be usable.
|
||||
compact: window.innerWidth <= 820,
|
||||
onCloseGroup,
|
||||
});
|
||||
rightPanel.appendChild(dock.dock);
|
||||
// Layers stays pinned below the dock so the layer list is always
|
||||
|
||||
@@ -36,16 +36,18 @@ export const TOOLS = {
|
||||
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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
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"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M6 18 18 6" opacity=".2"/><path d="M9 18 18 9" opacity=".5"/><path d="M12 18 18 12"/></svg>' },
|
||||
bucket: { label: 'Paint Bucket', 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 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z"/><path d="m5 2 5 5"/><path d="M2 13h15"/><path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z"/></svg>' },
|
||||
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>' },
|
||||
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="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a2.1 2.1 0 1 1-3 3l-2.1-2.1a2.1 2.1 0 0 1 0-3l-.9-.9Z"/></svg>' },
|
||||
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>' },
|
||||
pixel: { label: 'Pixel', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="4" y="4" width="7" height="7"/><rect x="13" y="13" width="7" height="7"/><rect x="13" y="4" width="7" height="7" opacity="0.35"/><rect x="4" y="13" width="7" height="7" opacity="0.35"/></svg>' },
|
||||
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="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a1 1 0 0 1 0 1.4l-1.6 1.6a1 1 0 0 1-1.4 0L12 8.4a1 1 0 0 1 0-1.4l1.6-1.6a1 1 0 0 1 1.4 0l.9.9Z"/></svg>' },
|
||||
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="M12.5 12V5.5a1.75 1.75 0 1 1 3.5 0V12"/><path d="M16 12v-1.5a1.5 1.5 0 0 1 3 0V16a5 5 0 0 1-5 5h-2.2a4.5 4.5 0 0 1-3.5-1.7l-3.2-4a1.6 1.6 0 0 1 2.4-2.1l1.8 1.9"/></svg>' },
|
||||
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>' },
|
||||
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>' },
|
||||
patch: { label: 'Patch', 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="3" width="10" height="10" rx="1" stroke-dasharray="3 2"/><path d="M13 13h8v8h-8z"/></svg>' },
|
||||
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>' },
|
||||
dodge: { label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m9.5 14.5-6 6"/><circle cx="14" cy="10" r="6" fill="currentColor"/></svg>' },
|
||||
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>' },
|
||||
liquify: { label: 'Liquify', 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="M3 8c2.5-3 5-3 7.5 0s5 3 7.5 0"/><path d="M3 14c2.5-3 5-3 7.5 0s5 3 7.5 0"/><circle cx="17.5" cy="19" r="2"/></svg>' },
|
||||
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>' },
|
||||
lasso: { label: 'Lasso', 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="M7 22a5 5 0 0 1-2-4"/><path d="M3.3 13.9A6.9 6.9 0 0 1 2 9c0-4 4-7 10-7s10 3 10 7-4 7-10 7"/><path d="M5 18a4.6 4.6 0 0 1-1.5-3.5"/><circle cx="5" cy="19" r="2"/></svg>' },
|
||||
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>' },
|
||||
@@ -64,7 +66,7 @@ export const TOOLS = {
|
||||
const SECTIONS = [
|
||||
['move'],
|
||||
[['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']],
|
||||
[['crop', 'transform'], ['brush', 'eraser'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn'], ['gradient', 'bucket'], 'eyedropper'],
|
||||
[['crop', 'transform'], ['brush', 'eraser', 'pixel'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn', 'liquify'], ['gradient', 'bucket'], 'eyedropper'],
|
||||
[['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']],
|
||||
[['hand', 'zoom']],
|
||||
];
|
||||
@@ -72,6 +74,12 @@ const SECTIONS = [
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
// The tool slots live in their own scroller so the colour swatches can sit
|
||||
// in a fixed footer below it. With the swatches inside the scroller they
|
||||
// were sticky over the last slots and swallowed their clicks.
|
||||
const scroller = document.createElement('div');
|
||||
scroller.className = 'ge-toolbar-scroll';
|
||||
toolbar.appendChild(scroller);
|
||||
const toolKeyMap = {};
|
||||
const slots = [];
|
||||
|
||||
@@ -126,6 +134,17 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
flyout.style.left = (r.right + 4 - cbX) + 'px';
|
||||
flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px';
|
||||
}
|
||||
// Keep the list inside the window. The rail's lower slots (brush with its
|
||||
// presets, clone, smudge) open a tall menu that would otherwise spill past
|
||||
// the bottom edge, leaving the lower rows impossible to click.
|
||||
const fw = flyout.offsetWidth;
|
||||
const fh = flyout.offsetHeight;
|
||||
const viewLeft = (parseFloat(flyout.style.left) || 0) + cbX;
|
||||
const viewTop = (parseFloat(flyout.style.top) || 0) + cbY;
|
||||
const maxLeft = Math.max(4, window.innerWidth - fw - 4);
|
||||
const maxTop = Math.max(4, window.innerHeight - fh - 4);
|
||||
if (viewLeft > maxLeft) flyout.style.left = (maxLeft - cbX) + 'px';
|
||||
if (viewTop > maxTop) flyout.style.top = (maxTop - cbY) + 'px';
|
||||
};
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
@@ -137,7 +156,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
if (!first) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
toolbar.appendChild(sep);
|
||||
scroller.appendChild(sep);
|
||||
}
|
||||
first = false;
|
||||
for (const entry of section) {
|
||||
@@ -158,9 +177,11 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const slot = { el: slotEl, face, members, current: faceId, items: [] };
|
||||
if (members.length > 1) {
|
||||
// Photoshop-style corner triangle marks a slot whose family has
|
||||
// more tools behind it.
|
||||
face.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
|
||||
const tri = face.querySelector('.ge-tool-flyout-tri');
|
||||
// more tools behind it. It lives on the slot (not on the face) so
|
||||
// re-rendering the face icon when a flyout member is picked never
|
||||
// wipes the hint away.
|
||||
slotEl.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
|
||||
const tri = slotEl.querySelector('.ge-tool-flyout-tri');
|
||||
tri?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
openFlyout(slotEl, face);
|
||||
@@ -189,12 +210,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
slot.items.push(item);
|
||||
}
|
||||
// Photoshop-style: the Brush slot's flyout also lists the brush
|
||||
// presets, so the Pixel brush (and any saved presets) are reachable
|
||||
// straight from the tool button. Picking one switches to the Brush
|
||||
// and applies the preset through the options-bar picker so the size
|
||||
// and stroke controls stay in sync.
|
||||
// presets, so any saved presets are reachable straight from the tool
|
||||
// button. Picking one switches to the Brush and applies the preset
|
||||
// through the options-bar picker so the size and stroke controls stay
|
||||
// in sync. Pixel is its own tool (above), so it is NOT listed here as a
|
||||
// brush type.
|
||||
if (members.includes('brush')) {
|
||||
const brushPresets = loadBrushPresets();
|
||||
const brushPresets = loadBrushPresets().filter((preset) => !preset.pixel);
|
||||
if (brushPresets.length) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-flyout-sep';
|
||||
@@ -208,7 +230,19 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
presetItem.type = 'button';
|
||||
presetItem.className = 'ge-tool-btn ge-tool-flyout-item ge-tool-flyout-preset';
|
||||
presetItem.dataset.brushPreset = preset.id;
|
||||
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${preset.name}</span>`);
|
||||
// Each preset row shows a glyph that reflects the preset's tip:
|
||||
// a 1px checker square for the pixel brush, a solid disc for a
|
||||
// hard round, a soft-edged disc for a soft round, and a mid disc
|
||||
// in between — so a row is recognisable at a glance.
|
||||
const soft = typeof preset.softness === 'number' ? preset.softness : 100;
|
||||
const presetIcon = preset.pixel
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="6" y="6" width="5" height="5"/><rect x="13" y="13" width="5" height="5"/><rect x="13" y="6" width="5" height="5" opacity="0.35"/><rect x="6" y="13" width="5" height="5" opacity="0.35"/></svg>'
|
||||
: soft <= 20
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6.5"/></svg>'
|
||||
: soft >= 70
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
||||
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
|
||||
presetItem.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setInner(face, 'brush');
|
||||
@@ -232,7 +266,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
});
|
||||
}
|
||||
slots.push(slot);
|
||||
toolbar.appendChild(slotEl);
|
||||
scroller.appendChild(slotEl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-label">File</span>
|
||||
</button>
|
||||
<div class="ge-file-menu dropdown" id="ge-file-menu" hidden>
|
||||
<div class="ge-menu-label">New</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="file-new" aria-haspopup="true" aria-expanded="false"><span>New Image</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="file-new">
|
||||
<button class="dropdown-item-compact" data-file-action="new-default"><span>Default</span><span class="dropdown-shortcut">1920 × 1080</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="new-square"><span>Square</span><span class="dropdown-shortcut">1080 × 1080</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="new-portrait"><span>Portrait</span><span class="dropdown-shortcut">1080 × 1350</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="new-story"><span>Story</span><span class="dropdown-shortcut">1080 × 1920</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="new-clipboard"><span>From Clipboard</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="new-custom"><span>Custom Size…</span><span class="dropdown-shortcut">Ctrl+N</span></button>
|
||||
</div>
|
||||
<div class="ge-menu-label">Open</div>
|
||||
<button class="dropdown-item-compact" data-file-action="import"><span>Import Image…</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="load-project"><span>Load Project…</span></button>
|
||||
@@ -464,7 +474,19 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-label">Window</span>
|
||||
</button>
|
||||
<div class="ge-window-menu dropdown" id="ge-window-menu" hidden>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="window-workspace">
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic & Web</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-minimal"><span class="dropdown-icon ge-window-check">●</span><span>Minimal</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-custom"><span class="dropdown-icon ge-window-check">●</span><span>Custom</span></button>
|
||||
<div class="ge-submenu-sep"></div>
|
||||
<button type="button" class="dropdown-item-compact ge-window-save" data-window-action="workspace-save"><span class="dropdown-icon">+</span><span>Save Current</span></button>
|
||||
</div>
|
||||
<div class="ge-menu-label">Panels</div>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="quick-edit"><span class="dropdown-icon ge-window-check">●</span><span>Quick Edit</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="options"><span class="dropdown-icon ge-window-check">●</span><span>Tool Options</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="layers"><span class="dropdown-icon ge-window-check">●</span><span>Layers</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="properties"><span class="dropdown-icon ge-window-check">●</span><span>Properties</span></button>
|
||||
@@ -480,13 +502,6 @@ export function buildTopbar() {
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="navigator"><span class="dropdown-icon ge-window-check">●</span><span>Navigator</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="history"><span class="dropdown-icon ge-window-check">●</span><span>History</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="actions"><span class="dropdown-icon ge-window-check">●</span><span>Actions</span></button>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="window-workspace">
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic & Web</span></button>
|
||||
</div>
|
||||
<div class="ge-menu-label">Help</div>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="shortcuts"><span class="dropdown-icon ge-window-check">●</span><span>Keyboard Shortcuts</span></button>
|
||||
</div>
|
||||
@@ -509,7 +524,7 @@ export function buildTopbar() {
|
||||
<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-maximize-toggle" title="Maximize editor" aria-label="Maximize editor" aria-pressed="false">
|
||||
<svg class="ge-max-icon" 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;"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
<svg class="ge-max-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
</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>
|
||||
|
||||
@@ -111,8 +111,8 @@ export function transformPopupHTML() {
|
||||
<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" />
|
||||
<label class="ge-transform-preserve-field" title="Preserve source pixels (applies the transform non-destructively)">
|
||||
<input type="checkbox" id="ge-transform-preserve-source" aria-label="Preserve source pixels" />
|
||||
<span>Preserve source pixels</span>
|
||||
</label>
|
||||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||||
|
||||
@@ -93,6 +93,12 @@ export function buildCanvasMenuItems(tool) {
|
||||
{ label: 'Expand', disabled: !sel, onSelect: () => invoke('selection.expand', { amount: 4 }) },
|
||||
{ label: 'Contract', disabled: !sel, onSelect: () => invoke('selection.contract', { amount: 4 }) },
|
||||
);
|
||||
if (sel) {
|
||||
push(
|
||||
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
|
||||
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
|
||||
);
|
||||
}
|
||||
sep();
|
||||
} else if (tool === 'crop' || state.cropRect) {
|
||||
const cropping = !!(state.cropRect || state.cropping || state.cropMoving);
|
||||
|
||||
@@ -136,12 +136,15 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
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.
|
||||
// Lasso 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') return;
|
||||
if (state.tool !== 'lasso' && state.tool !== 'crop') 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;
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
@@ -302,7 +305,10 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
if (state.tool === 'lasso' && !directPan) return;
|
||||
// With the crop (or lasso) 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 (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;
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* Paste + drag-and-drop import handlers. Both add an image to the
|
||||
* editor as a new layer:
|
||||
*
|
||||
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by
|
||||
* lasso copy/cut), then falls back to the system clipboard's
|
||||
* `image/*` items. Layer is named "Pasted Selection" or "Pasted"
|
||||
* and becomes active; the tool snaps to Move so the user can
|
||||
* reposition it immediately.
|
||||
* - Paste (Ctrl+V): uses the system clipboard's `image/*` items first
|
||||
* (so an external screenshot always wins), then falls back to
|
||||
* `state.internalClipboard` (set by lasso copy/cut) when the OS
|
||||
* clipboard has no image. Layer is named "Pasted Selection" or
|
||||
* "Pasted" and becomes active; the tool snaps to Move so the user
|
||||
* can reposition it immediately.
|
||||
* - Drop: any `image/*` file dragged from the OS / another tab.
|
||||
* Shows a "Drop image to add as new layer" overlay mid-drag. Each
|
||||
* dropped image is routed through `handleImportedImage` so canvas-
|
||||
@@ -62,6 +63,7 @@ export function wireClipboardAndDrop({
|
||||
};
|
||||
|
||||
// ── Paste ──
|
||||
let pasteKeyPending = false;
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen || state.container !== container) return;
|
||||
// Editable fields INSIDE the editor keep native paste (the Clipboard button
|
||||
@@ -80,6 +82,7 @@ export function wireClipboardAndDrop({
|
||||
|
||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||
if (imageItem) {
|
||||
pasteKeyPending = false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const blob = imageItem.getAsFile();
|
||||
@@ -95,6 +98,7 @@ export function wireClipboardAndDrop({
|
||||
return;
|
||||
}
|
||||
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
|
||||
pasteKeyPending = false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
@@ -102,13 +106,13 @@ export function wireClipboardAndDrop({
|
||||
}, { capture: true, signal });
|
||||
|
||||
// ── Ctrl/Cmd+V ──
|
||||
// Paste the internal selection clipboard directly. On an insecure origin
|
||||
// (the editor is served over http on a LAN IP) `navigator.clipboard` is
|
||||
// unavailable, so copying a selection never reaches the OS clipboard and the
|
||||
// native `paste` event above either carries stale `text/plain` or nothing —
|
||||
// leaving Ctrl+V a silent no-op. Handling the keystroke here makes
|
||||
// copy→paste work regardless of the platform clipboard (the toolbar buttons
|
||||
// already did, which is why they worked).
|
||||
// Do NOT intercept the keystroke: preventing the default here bypasses the
|
||||
// browser's own `paste` event and always re-pasted `state.internalClipboard`,
|
||||
// so a screenshot taken outside the gallery could never be pasted. The paste
|
||||
// listener above always prefers real `image/*` clipboard items; this key
|
||||
// handler only covers the insecure-origin case where copying a selection
|
||||
// never reached the OS clipboard, so the paste event delivers no image (and
|
||||
// possibly stale text) and nothing usable arrives.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!(state.editorOpen && state.container === container)) return;
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
||||
@@ -116,9 +120,15 @@ export function wireClipboardAndDrop({
|
||||
if (!state.internalClipboard) return;
|
||||
const target = e.target;
|
||||
if (target && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
pasteKeyPending = true;
|
||||
setTimeout(() => {
|
||||
// The paste event runs before this timer and clears the flag whenever it
|
||||
// received a real clipboard image (or already pasted the internal copy).
|
||||
if (!pasteKeyPending) return;
|
||||
pasteKeyPending = false;
|
||||
if (!state.internalClipboard) return;
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
}, 0);
|
||||
}, { capture: true, signal });
|
||||
|
||||
// ── Drag-and-drop ──
|
||||
|
||||
@@ -198,10 +198,17 @@ function normalizeCropRect(editorState, rect) {
|
||||
// Accept either {w,h} (internal) or {width,height} (control-op params).
|
||||
const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
|
||||
const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
|
||||
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(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
|
||||
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
|
||||
// The rect may reach BEYOND the current image on any side: a crop that
|
||||
// overshoots an edge EXPANDS the canvas there (the new region is
|
||||
// transparent) instead of being clamped back inside. renderWindow() already
|
||||
// places the source at (-x,-y) in an output of any size, so allowing
|
||||
// negative x/y and out-of-bounds width/height is all that is needed.
|
||||
// Only guard against absurd values.
|
||||
const MAX = 20000;
|
||||
const x = Math.max(-MAX, Math.min(MAX, Math.round(rect.x)));
|
||||
const y = Math.max(-MAX, Math.min(MAX, Math.round(rect.y)));
|
||||
const width = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
|
||||
const height = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,17 +24,29 @@ function pixelSlots(snapshot) {
|
||||
return slots;
|
||||
}
|
||||
|
||||
function samePixels(image, other) {
|
||||
if (image === other) return true;
|
||||
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
|
||||
const length = image.data.length;
|
||||
// Compare 4 bytes at a time: the byte-by-byte loop dominated selection
|
||||
// commits on large documents (every layer of the previous snapshot was
|
||||
// re-scanned one byte at a time).
|
||||
if (length % 4 === 0) {
|
||||
const a = new Uint32Array(image.data.buffer, image.data.byteOffset, length >> 2);
|
||||
const b = new Uint32Array(other.data.buffer, other.data.byteOffset, length >> 2);
|
||||
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < length; i += 1) if (image.data[i] !== other.data[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
const match = candidates.find(other => samePixels(image, other));
|
||||
if (match) object[key] = match;
|
||||
}
|
||||
return snapshot;
|
||||
|
||||
@@ -101,9 +101,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
// Fields and text layers own native editing, including undo and clipboard.
|
||||
// Let Delete/Backspace out of non-text widgets (range/checkbox/button) so
|
||||
// they still delete the selected layer.
|
||||
// they still delete the selected layer. Enter still confirms an active
|
||||
// transform even from a transform field, so typing a value then hitting
|
||||
// Enter accepts the transform instead of doing nothing.
|
||||
if (isTypingTarget(e.target) &&
|
||||
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace'))) return;
|
||||
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace')) &&
|
||||
!(state.transformActive && e.key === 'Enter')) return;
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -185,6 +188,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
|
||||
// Ctrl/Cmd+N — new document. The File menu owns the size dialog, so
|
||||
// click its row rather than duplicating the prompt here.
|
||||
if (!e.shiftKey && !e.altKey && e.code === 'KeyN') {
|
||||
const row = document.querySelector('[data-file-action="new-custom"]');
|
||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
||||
}
|
||||
// Ctrl/Cmd+T — free transform (Photoshop). Ctrl+Shift+T stays the canvas
|
||||
// resize prompt and Ctrl+Alt+T remains a legacy alias.
|
||||
if (!e.shiftKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
|
||||
@@ -198,6 +207,13 @@ export function wireKeyboardShortcuts(deps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+Shift+E — stamp visible: merge every visible layer into a new
|
||||
// layer on top of the stack while keeping the originals (Photoshop).
|
||||
// Flatten Copy in the layer footer already does exactly that, so click it.
|
||||
if (e.altKey && e.shiftKey && e.code === 'KeyE') {
|
||||
const stamp = document.getElementById('ge-flatten');
|
||||
if (stamp) { e.preventDefault(); e.stopPropagation(); stamp.click(); return; }
|
||||
}
|
||||
// Ctrl+Alt+J — new empty layer.
|
||||
if (e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
@@ -205,6 +221,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.
|
||||
if (e.altKey && e.code === 'KeyL') {
|
||||
const row = document.querySelector('[data-image-action="adj-levels"]');
|
||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
||||
}
|
||||
// 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') {
|
||||
@@ -335,7 +357,20 @@ export function wireKeyboardShortcuts(deps) {
|
||||
if (toolId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);
|
||||
// A slot can host several tools (gradient + paint bucket, brush + eraser
|
||||
// + pixel). The main key re-activates whatever member the slot is
|
||||
// currently showing, so picking Paint Bucket and then tapping G keeps
|
||||
// Paint Bucket instead of snapping back to the default. A key for a
|
||||
// secondary member (e.g. E for eraser) still picks that exact tool.
|
||||
const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);
|
||||
const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;
|
||||
let toolBtn = null;
|
||||
if (slotEl) {
|
||||
const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');
|
||||
const defaultTool = firstMember ? firstMember.dataset.toolOption : null;
|
||||
if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');
|
||||
}
|
||||
toolBtn = toolBtn || toolbar.querySelector(`[data-tool="${toolId}"]`) || flyoutItem;
|
||||
if (toolBtn) toolBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export function defaultAdjParams(type) {
|
||||
/** Preset names for the retained adjustment popup. */
|
||||
export function adjustmentPresetOptions(type) {
|
||||
const options = {
|
||||
'brightness-contrast': ['Default', 'High Contrast'],
|
||||
'brightness-contrast': ['Default', 'Brighten', 'Darken', 'Punch', 'High Contrast', 'Low Contrast', 'Faded'],
|
||||
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
|
||||
'white-balance': ['Default', 'Warm', 'Cool'],
|
||||
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
|
||||
@@ -135,8 +135,18 @@ export function adjustmentPresetOptions(type) {
|
||||
export function adjustmentPresetParams(type, preset) {
|
||||
const params = defaultAdjParams(type);
|
||||
switch (`${type}:${preset}`) {
|
||||
case 'brightness-contrast:Brighten':
|
||||
return { ...params, brightness: 1.18 };
|
||||
case 'brightness-contrast:Darken':
|
||||
return { ...params, brightness: 0.82 };
|
||||
case 'brightness-contrast:Punch':
|
||||
return { ...params, brightness: 1.04, contrast: 1.22 };
|
||||
case 'brightness-contrast:High Contrast':
|
||||
return { ...params, contrast: 1.28 };
|
||||
case 'brightness-contrast:Low Contrast':
|
||||
return { ...params, contrast: 0.75 };
|
||||
case 'brightness-contrast:Faded':
|
||||
return { ...params, brightness: 1.1, contrast: 0.85 };
|
||||
case 'exposure:Lift Exposure':
|
||||
return { ...params, exposure: 0.45 };
|
||||
case 'exposure:Lower Exposure':
|
||||
|
||||
@@ -110,6 +110,88 @@ export function createLayerPanelRenderer(deps) {
|
||||
return Date.now() < (window.__geSuppressLayerTapUntil || 0);
|
||||
}
|
||||
|
||||
// Photoshop lets you drag a layer effect onto another layer to copy it.
|
||||
// Rebuild the effect with a fresh id and its own mask canvas so the two
|
||||
// layers never share mutable pixels.
|
||||
function cloneEffectForCopy(effect) {
|
||||
const copy = normalizeEffect({
|
||||
...effect,
|
||||
id: `effect-${Math.random().toString(36).slice(2, 9)}`,
|
||||
mask: effect.mask
|
||||
? { ...effect.mask, id: `effect-mask-${Math.random().toString(36).slice(2, 9)}` }
|
||||
: null,
|
||||
});
|
||||
if (copy.mask && effect.mask?.canvas) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = effect.mask.canvas.width;
|
||||
canvas.height = effect.mask.canvas.height;
|
||||
canvas.getContext('2d').drawImage(effect.mask.canvas, 0, 0);
|
||||
copy.mask.canvas = canvas;
|
||||
copy.mask.ctx = canvas.getContext('2d');
|
||||
copy.mask.canvasW = canvas.width;
|
||||
copy.mask.canvasH = canvas.height;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Drag an effect sub-row onto any layer or group row to copy that effect.
|
||||
// Pointer events (not HTML5 DnD) keep the existing click-to-edit behaviour.
|
||||
function wireEffectDrag(sub, owner, effect) {
|
||||
sub.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0 || event.pointerType === 'touch') return;
|
||||
if (event.target.closest('button, input, .ge-layer-btn, .ge-layer-opacity')) return;
|
||||
if (shouldIgnoreLayerTap()) return;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
let ghost = null;
|
||||
let targetRow = null;
|
||||
const highlight = (row) => {
|
||||
if (targetRow && targetRow !== row) targetRow.classList.remove('is-effect-drop-target');
|
||||
targetRow = row || null;
|
||||
targetRow?.classList.add('is-effect-drop-target');
|
||||
};
|
||||
const move = (ev) => {
|
||||
if (!ghost) {
|
||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 5) return;
|
||||
ghost = document.createElement('div');
|
||||
ghost.className = 'ge-effect-drag-ghost';
|
||||
ghost.textContent = effectLabel(effect.type);
|
||||
document.body.appendChild(ghost);
|
||||
sub.classList.add('is-effect-dragging');
|
||||
// A drop is not a click on the effect name.
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 400;
|
||||
}
|
||||
ghost.style.left = `${ev.clientX + 12}px`;
|
||||
ghost.style.top = `${ev.clientY + 12}px`;
|
||||
const hit = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||
highlight(hit?.closest?.('.ge-layer-item[data-layer-id], .ge-layer-group-row[data-group-id]') || null);
|
||||
};
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move, true);
|
||||
document.removeEventListener('pointerup', up, true);
|
||||
document.removeEventListener('pointercancel', up, true);
|
||||
const row = targetRow;
|
||||
highlight(null);
|
||||
ghost?.remove();
|
||||
sub.classList.remove('is-effect-dragging');
|
||||
if (!ghost || !row) return;
|
||||
const layer = row.dataset.layerId
|
||||
? state.layers.find(item => item.id === row.dataset.layerId)
|
||||
: (state.layerGroups || []).find(item => item.id === row.dataset.groupId);
|
||||
if (!layer || layer === owner) return;
|
||||
saveState(`Copy ${effectLabel(effect.type)}`);
|
||||
if (!Array.isArray(layer.effects)) layer.effects = [];
|
||||
layer.effects.push(cloneEffectForCopy(effect));
|
||||
composite();
|
||||
render();
|
||||
uiModule?.showToast?.(`${effectLabel(effect.type)} copied to ${layer.name}`);
|
||||
};
|
||||
document.addEventListener('pointermove', move, true);
|
||||
document.addEventListener('pointerup', up, true);
|
||||
document.addEventListener('pointercancel', up, true);
|
||||
});
|
||||
}
|
||||
|
||||
function rowTargets(layer) {
|
||||
const selected = selectedLayers(state);
|
||||
return selected.length > 1 && selected.some(item => item.id === layer.id)
|
||||
@@ -914,7 +996,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
effectName.className = 'ge-layer-name ge-adj-sub-name';
|
||||
effectName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
|
||||
effectName.lastElementChild.textContent = effectLabel(effect.type);
|
||||
effectName.title = 'Edit group effect';
|
||||
effectName.title = 'Edit group effect (drag onto another layer to copy)';
|
||||
effectName.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
editRetainedEffect?.(group, effect, effectName);
|
||||
@@ -974,6 +1056,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
render();
|
||||
});
|
||||
effectControls.appendChild(remove);
|
||||
wireEffectDrag(sub, group, effect);
|
||||
sub.append(visibility, effectName, effectOpacity, effectControls);
|
||||
list.appendChild(sub);
|
||||
}
|
||||
@@ -1866,7 +1949,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const sName = document.createElement('span');
|
||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
|
||||
sName.title = 'Edit effect';
|
||||
sName.title = 'Edit effect (drag onto another layer to copy)';
|
||||
sName.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
editRetainedEffect?.(layer, effect, sName);
|
||||
@@ -1971,6 +2054,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
render();
|
||||
});
|
||||
sControls.appendChild(remove);
|
||||
wireEffectDrag(sub, layer, effect);
|
||||
sub.append(sVis, sName, sOp, sControls);
|
||||
list.appendChild(sub);
|
||||
}
|
||||
@@ -2140,14 +2224,16 @@ export function createLayerPanelRenderer(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the shared dragSort module — limit drag-init to the grip
|
||||
// handle so row body clicks still activate. Called every render
|
||||
// because `enable()` cleans up the previous instance keyed on
|
||||
// instanceKey.
|
||||
// Wire the shared dragSort module. The whole row is draggable (grip,
|
||||
// thumbnail, name) with a small movement threshold so a plain click
|
||||
// still activates the layer and a double-click still renames it;
|
||||
// buttons/inputs keep their own behaviour. Called every render because
|
||||
// `enable()` cleans up the previous instance keyed on instanceKey.
|
||||
if (dragSortModule) {
|
||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||
instanceKey: 'ge-layers',
|
||||
handleSelector: '.ge-layer-drag',
|
||||
threshold: 4,
|
||||
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
||||
onReorder: (orderedItems) => {
|
||||
// DOM is top→bottom = reverse of array order, so the new
|
||||
// array is the reverse of the DOM order.
|
||||
|
||||
@@ -47,8 +47,42 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
|
||||
const perspectiveY = target.perspectiveY || 0;
|
||||
const skewPadX = Math.abs(Math.tan(skewX * Math.PI / 180)) * scaledHeight;
|
||||
const skewPadY = Math.abs(Math.tan(skewY * Math.PI / 180)) * scaledWidth;
|
||||
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin + skewPadX));
|
||||
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos + skewPadY));
|
||||
|
||||
// Free distort (Ctrl/Cmd+drag): the four corners as a quad in the layer's
|
||||
// local (unrotated) space, plus its axis-aligned bounds.
|
||||
const distort = target.distort || null;
|
||||
let quad = null;
|
||||
if (distort) {
|
||||
const corners = [['tl', -0.5, -0.5], ['tr', 0.5, -0.5], ['br', 0.5, 0.5], ['bl', -0.5, 0.5]];
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const points = corners.map(([key, sx, sy]) => {
|
||||
const delta = distort[key] || { x: 0, y: 0 };
|
||||
const px = (sx + delta.x) * scaledWidth;
|
||||
const py = (sy + delta.y) * scaledHeight;
|
||||
minX = Math.min(minX, px);
|
||||
minY = Math.min(minY, py);
|
||||
maxX = Math.max(maxX, px);
|
||||
maxY = Math.max(maxY, py);
|
||||
return { x: px, y: py };
|
||||
});
|
||||
quad = {
|
||||
points,
|
||||
minX,
|
||||
minY,
|
||||
quadW: Math.max(1, maxX - minX),
|
||||
quadH: Math.max(1, maxY - minY),
|
||||
};
|
||||
}
|
||||
const quadW = quad ? quad.quadW : scaledWidth;
|
||||
const quadH = quad ? quad.quadH : scaledHeight;
|
||||
const quadCx = quad ? quad.minX + quadW / 2 : 0;
|
||||
const quadCy = quad ? quad.minY + quadH / 2 : 0;
|
||||
|
||||
const width = Math.max(1, Math.round(quadW * absCos + quadH * absSin + skewPadX));
|
||||
const height = Math.max(1, Math.round(quadW * absSin + quadH * absCos + skewPadY));
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
@@ -60,13 +94,15 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
|
||||
skewY,
|
||||
perspectiveX,
|
||||
perspectiveY,
|
||||
distort,
|
||||
quad,
|
||||
width,
|
||||
height,
|
||||
centerX,
|
||||
centerY,
|
||||
centerX: centerX + (quadCx * cos - quadCy * sin),
|
||||
centerY: centerY + (quadCx * sin + quadCy * cos),
|
||||
offset: {
|
||||
x: Math.round(centerX - width / 2),
|
||||
y: Math.round(centerY - height / 2),
|
||||
x: Math.round(centerX + (quadCx * cos - quadCy * sin) - width / 2),
|
||||
y: Math.round(centerY + (quadCx * sin + quadCy * cos) - height / 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export function createColorPanels({
|
||||
zoomIn = null,
|
||||
zoomOut = null,
|
||||
zoomFit = null,
|
||||
applyGradient = null,
|
||||
} = {}) {
|
||||
if (!state) throw new Error('createColorPanels requires editor state');
|
||||
|
||||
@@ -119,10 +120,24 @@ export function createColorPanels({
|
||||
const swatchCol = el('div', 'ge-swatch-col');
|
||||
const suggestRow = el('div', 'ge-swatch-row ge-swatch-suggest');
|
||||
const recentRow = el('div', 'ge-swatch-row ge-swatch-recent');
|
||||
const favRow = el('div', 'ge-swatch-row ge-swatch-fav');
|
||||
const sugLabel = el('div', 'ge-swatch-row-label'); sugLabel.textContent = 'Suggestions';
|
||||
const recLabel = el('div', 'ge-swatch-row-label'); recLabel.textContent = 'Recent';
|
||||
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow);
|
||||
const favLabel = el('div', 'ge-swatch-row-label'); favLabel.textContent = 'Favorites';
|
||||
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow, favLabel, favRow);
|
||||
swatchBody.appendChild(swatchCol);
|
||||
const FAV_KEY = 'ge-color-favorites';
|
||||
const getFavoriteColors = () => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(FAV_KEY) || '[]');
|
||||
return Array.isArray(v) ? v.filter((h) => /^#[0-9a-f]{6}$/i.test(h)) : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
const setFavoriteColors = (list) => {
|
||||
try { localStorage.setItem(FAV_KEY, JSON.stringify(list.slice(0, 24))); } catch { /* storage unavailable */ }
|
||||
};
|
||||
const isFavorite = (hex) => getFavoriteColors().some((h) => h.toLowerCase() === String(hex || '').toLowerCase());
|
||||
let hexFav = null;
|
||||
const _syncForeground = (hex) => {
|
||||
setColor(hex);
|
||||
// Mirror the pick into the rail's foreground swatch so the whole editor
|
||||
@@ -141,6 +156,11 @@ export function createColorPanels({
|
||||
recentRow.innerHTML = recs.length
|
||||
? recs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
|
||||
: '<span class="ge-mini-empty">none yet</span>';
|
||||
const favs = getFavoriteColors();
|
||||
favRow.innerHTML = favs.length
|
||||
? favs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
|
||||
: '<span class="ge-mini-empty">star a colour to save it</span>';
|
||||
if (typeof hexFav !== 'undefined' && hexFav) hexFav.classList.toggle('active', isFavorite(cur));
|
||||
};
|
||||
|
||||
// Inline picker: an always-visible SV square + hue strip so the panel reads
|
||||
@@ -163,7 +183,46 @@ export function createColorPanels({
|
||||
hexInput.autocomplete = 'off';
|
||||
hexInput.setAttribute('aria-label', 'Hex colour code');
|
||||
hexInput.value = String(state.color || '#000000').replace(/^#/, '');
|
||||
hexRow.append(hexHash, hexInput);
|
||||
const hexCopy = el('button', 'ge-hex-copy');
|
||||
hexCopy.type = 'button';
|
||||
hexCopy.title = 'Copy hex colour';
|
||||
hexCopy.setAttribute('aria-label', 'Copy hex colour');
|
||||
hexCopy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>';
|
||||
hexCopy.addEventListener('click', async () => {
|
||||
const hex = '#' + String(hexInput.value || state.color || '').replace(/^#/, '');
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(hex);
|
||||
else throw new Error('clipboard unavailable');
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = hex;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* ignore */ }
|
||||
ta.remove();
|
||||
}
|
||||
hexCopy.classList.add('copied');
|
||||
clearTimeout(hexCopy._copiedTimer);
|
||||
hexCopy._copiedTimer = setTimeout(() => hexCopy.classList.remove('copied'), 900);
|
||||
});
|
||||
hexFav = el('button', 'ge-hex-copy ge-hex-fav');
|
||||
hexFav.type = 'button';
|
||||
hexFav.title = 'Save to favorites';
|
||||
hexFav.setAttribute('aria-label', 'Save colour to favorites');
|
||||
hexFav.innerHTML = '<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="M12 3.5l2.6 5.3 5.9.86-4.25 4.14 1 5.86L12 17l-5.25 2.72 1-5.86L3.5 9.66l5.9-.86z"/></svg>';
|
||||
hexFav.addEventListener('click', () => {
|
||||
const hex = '#' + String(hexInput.value || state.color || '').replace(/^#/, '');
|
||||
const h = hex.toLowerCase();
|
||||
if (!/^#[0-9a-f]{6}$/.test(h)) return;
|
||||
const list = getFavoriteColors().map((x) => x.toLowerCase());
|
||||
const i = list.indexOf(h);
|
||||
if (i >= 0) list.splice(i, 1); else list.unshift(h);
|
||||
setFavoriteColors(list);
|
||||
renderInlineSwatches();
|
||||
});
|
||||
hexRow.append(hexHash, hexInput, hexCopy, hexFav);
|
||||
swatchBody.appendChild(hexRow);
|
||||
const _setHex = (hex) => {
|
||||
if (hexInput && document.activeElement !== hexInput) hexInput.value = String(hex || '').replace(/^#/, '');
|
||||
@@ -262,6 +321,10 @@ export function createColorPanels({
|
||||
btn.title = name;
|
||||
btn.style.background = css;
|
||||
btn.addEventListener('click', () => {
|
||||
// Pick up the gradient tool first (so the colors land in its controls),
|
||||
// then load the preset's stops and paint it across the layer — clicking a
|
||||
// preset should actually apply the gradient, not just stage the colors.
|
||||
document.querySelector('[data-tool="gradient"]')?.click();
|
||||
const start = document.getElementById('ge-gradient-start');
|
||||
const end = document.getElementById('ge-gradient-end');
|
||||
const type = document.getElementById('ge-gradient-type');
|
||||
@@ -270,9 +333,10 @@ export function createColorPanels({
|
||||
end.value = stops[stops.length - 1];
|
||||
start.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (type) { type.value = 'linear'; type.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
if (type) { type.value = 'linear-gradient'; type.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
requestRender();
|
||||
}
|
||||
applyGradient?.();
|
||||
});
|
||||
gradientGrid.appendChild(btn);
|
||||
});
|
||||
@@ -321,8 +385,27 @@ export function createColorPanels({
|
||||
const histBody = el('div', 'ge-histogram-body');
|
||||
histBody.appendChild(histCanvas);
|
||||
const histogram = panel('histogram', '', histBody);
|
||||
// Pick a layer that actually has raster pixels. The active layer can be a
|
||||
// group/adjustment/text layer without a `canvas`, which used to make
|
||||
// drawHistogram throw (and the panel stay blank); fall back to the topmost
|
||||
// visible raster layer.
|
||||
const histogramLayer = () => {
|
||||
const layers = Array.isArray(state?.layers) ? state.layers : [];
|
||||
const active = layers.find((l) => l.id === state.activeLayerId);
|
||||
if (active && active.canvas && active.visible !== false) return active;
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const l = layers[i];
|
||||
if (l.canvas && l.visible !== false && l.kind !== 'adjustment') return l;
|
||||
}
|
||||
return active || layers[layers.length - 1] || null;
|
||||
};
|
||||
const redrawHistogram = () => {
|
||||
try { drawHistogram(histCanvas, activeLayer(state)); } catch { /* not ready */ }
|
||||
const layer = histogramLayer();
|
||||
if (!layer || !layer.canvas) {
|
||||
histCanvas.getContext('2d')?.clearRect(0, 0, histCanvas.width, histCanvas.height);
|
||||
return;
|
||||
}
|
||||
try { drawHistogram(histCanvas, layer); } catch { /* not ready */ }
|
||||
};
|
||||
|
||||
// ---- Info -------------------------------------------------------------
|
||||
|
||||
@@ -144,15 +144,15 @@ export function createPrecisionGuides({ canvasArea, composite, saveState, schedu
|
||||
function drawDocumentOverlay(ctx) {
|
||||
if (!ctx || !state.imgWidth || !state.imgHeight) return;
|
||||
ctx.save();
|
||||
const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');
|
||||
if (state.gridVisible || pixelBrush) {
|
||||
const base = pixelBrush
|
||||
? Math.max(1, Math.round(Number(state.brushSize) || 1))
|
||||
: Math.max(2, Number(state.gridSize) || 16);
|
||||
const multiplier = pixelBrush ? 1 : Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
|
||||
// The pixel grid is shown only when the user turns on View > Grid. The
|
||||
// Pixel tool used to force a strong blue grid over the whole document,
|
||||
// which read as an unwanted blue tint over the image.
|
||||
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;
|
||||
const strong = pixelBrush ? 'rgba(122, 208, 255, 0.55)' : 'rgba(110, 180, 220, 0.42)';
|
||||
const weak = pixelBrush ? 'rgba(122, 208, 255, 0.30)' : 'rgba(110, 180, 220, 0.22)';
|
||||
const strong = 'rgba(110, 180, 220, 0.42)';
|
||||
const weak = 'rgba(110, 180, 220, 0.22)';
|
||||
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);
|
||||
|
||||
@@ -285,18 +285,111 @@ export function selectionBoundaryPixels(mask, threshold = 128) {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0) {
|
||||
/**
|
||||
* Boundary pixels AND the selected-flag grid for a mask, computed in one
|
||||
* pass. The flags let the ants painter find each outline pixel's exposed
|
||||
* edges without re-reading the mask.
|
||||
*/
|
||||
export function selectionBoundary(mask, threshold = 128) {
|
||||
const fallback = { width: mask?.width || 0, height: mask?.height || 0, pixels: [], flags: null };
|
||||
if (!mask?.width || !mask?.height) return fallback;
|
||||
let data;
|
||||
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
|
||||
catch { return fallback; }
|
||||
const width = mask.width;
|
||||
const height = mask.height;
|
||||
const flags = new Uint8Array(width * height);
|
||||
// First/last selected pixel of each row, so the outline pass can skip the
|
||||
// empty margins above, below and around the selection instead of testing
|
||||
// every pixel of a large canvas.
|
||||
const firsts = new Int32Array(height).fill(-1);
|
||||
const lasts = new Int32Array(height).fill(-1);
|
||||
for (let y = 0, i = 0, alpha = 3; y < height; y += 1) {
|
||||
let first = -1, last = -1;
|
||||
for (let x = 0; x < width; x += 1, i += 1, alpha += 4) {
|
||||
if (data[alpha] < threshold) continue;
|
||||
flags[i] = 1;
|
||||
if (first < 0) first = x;
|
||||
last = x;
|
||||
}
|
||||
firsts[y] = first;
|
||||
lasts[y] = last;
|
||||
}
|
||||
// Direct index arithmetic instead of a closure call per neighbour: this runs
|
||||
// over every pixel of the image on each new selection, and the five function
|
||||
// calls per pixel were a measurable share of the pen-close cost on big docs.
|
||||
const pixels = [];
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const first = firsts[y];
|
||||
if (first < 0) continue;
|
||||
const last = lasts[y];
|
||||
const row = y * width;
|
||||
const above = row - width;
|
||||
const below = row + width;
|
||||
const top = y === 0;
|
||||
const bottom = y === height - 1;
|
||||
for (let x = first; x <= last; x += 1) {
|
||||
const index = row + x;
|
||||
if (flags[index] !== 1) continue;
|
||||
if ((x === 0 || flags[index - 1] !== 1)
|
||||
|| (x === width - 1 || flags[index + 1] !== 1)
|
||||
|| (top || flags[above + x] !== 1)
|
||||
|| (bottom || flags[below + x] !== 1)) {
|
||||
pixels.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { width, height, pixels, flags };
|
||||
}
|
||||
|
||||
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0, zoom = 1, flags = null) {
|
||||
if (!ctx || !pixels?.length || !width || !height) return;
|
||||
const image = ctx.createImageData(width, height);
|
||||
if (!flags || typeof Path2D === 'undefined') {
|
||||
// Fallback (no mask context / non-browser): per-pixel fill in image space.
|
||||
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);
|
||||
return;
|
||||
}
|
||||
// Screen-constant marching ants. `width`/`height` are the MASK (image)
|
||||
// dimensions; the caller's overlay bitmap is rendered at SCREEN resolution
|
||||
// (image size × zoom), so we multiply the image-space outline into screen
|
||||
// pixels and stroke a flat 1px line — no bitmap resampling, so the outline
|
||||
// stays the same thickness and opacity at every zoom level.
|
||||
const scale = zoom > 0 ? zoom : 1;
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.setLineDash([]);
|
||||
const white = new Path2D();
|
||||
const black = new Path2D();
|
||||
const selected = (x, y) => x >= 0 && y >= 0 && x < width && y < height && flags[y * width + x] === 1;
|
||||
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;
|
||||
const x0 = x * scale;
|
||||
const y0 = y * scale;
|
||||
const x1 = (x + 1) * scale;
|
||||
const y1 = (y + 1) * scale;
|
||||
const sx = Math.floor((x + 0.5) * scale);
|
||||
const sy = Math.floor((y + 0.5) * scale);
|
||||
const path = ((sx + sy + phase) % 8) < 4 ? white : black;
|
||||
if (!selected(x, y - 1)) { path.moveTo(x0, y0); path.lineTo(x1, y0); }
|
||||
if (!selected(x, y + 1)) { path.moveTo(x0, y1); path.lineTo(x1, y1); }
|
||||
if (!selected(x - 1, y)) { path.moveTo(x0, y0); path.lineTo(x0, y1); }
|
||||
if (!selected(x + 1, y)) { path.moveTo(x1, y0); path.lineTo(x1, y1); }
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.stroke(white);
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.stroke(black);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ export function createShortcutsPopover() {
|
||||
outside = (e) => {
|
||||
if (el.contains(e.target)) return;
|
||||
if (e.target.closest('#ge-shortcuts-btn')) return;
|
||||
// A click on a topbar dropdown row is an explicit toggle (e.g. the
|
||||
// Window menu's "Keyboard Shortcuts" row), not a click-away.
|
||||
if (e.target.closest && e.target.closest('.ge-topbar .dropdown')) return;
|
||||
toggleShortcuts(false);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', outside, true), 0);
|
||||
|
||||
@@ -163,8 +163,10 @@ export function wireSliderUx({ registerDocClickAway }) {
|
||||
const crect = chip.getBoundingClientRect();
|
||||
const rrect = row.getBoundingClientRect();
|
||||
inp.style.left = (crect.left - rrect.left) + 'px';
|
||||
inp.style.top = (crect.top - rrect.top - 1) + 'px';
|
||||
inp.style.width = Math.max(40, crect.width + 8) + 'px';
|
||||
// Vertically centre the editor on the chip it replaced (the input is
|
||||
// not necessarily the same height as the chip).
|
||||
inp.style.top = (crect.top - rrect.top + (crect.height - inp.offsetHeight) / 2) + 'px';
|
||||
inp.focus();
|
||||
inp.select();
|
||||
const commit = () => {
|
||||
|
||||
@@ -54,6 +54,10 @@ export const state = {
|
||||
transformPendingSkewY: 0,
|
||||
transformPendingPerspX: 0,
|
||||
transformPendingPerspY: 0,
|
||||
// Free distort (Ctrl/Cmd+drag a handle): per-corner offsets in frame-normal
|
||||
// units (each component is a fraction of the frame width/height; ±0.5 is
|
||||
// the frame half-size). null when no handle has been distorted.
|
||||
transformPendingDistort: null,
|
||||
transformAspectLock: true,
|
||||
// Keep raster transforms source-backed unless the user explicitly opts out
|
||||
// in the Transform popup.
|
||||
@@ -108,6 +112,11 @@ export const state = {
|
||||
brushFlow: 100,
|
||||
brushSoftness: 100,
|
||||
smudgeStrength: 65,
|
||||
dodgeBurnIntensity: 25,
|
||||
// Liquify warp brush — mode plus the dab radius (image px) and strength.
|
||||
liquifyMode: 'push',
|
||||
liquifySize: 120,
|
||||
liquifyStrength: 50,
|
||||
eraserOpacity: 100,
|
||||
eraserFlow: 100,
|
||||
eraserSoftness: 100,
|
||||
|
||||
@@ -3,7 +3,7 @@ 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']);
|
||||
const PAINT_TOOLS = new Set(['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function toolSettings(tool) {
|
||||
if (tool === 'eraser') return { opacity: state.eraserOpacity, flow: state.eraserFlow, softness: state.eraserSoftness };
|
||||
@@ -295,11 +295,26 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
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 (tool === 'dodge') {
|
||||
// Lighten the pixels already there rather than stamping flat white.
|
||||
// Intensity scales the per-dab strength so a stroke can be gentle or
|
||||
// punchy; the cap keeps it building up gradually like Photoshop.
|
||||
const intensity = Number.isFinite(state.dodgeBurnIntensity) ? state.dodgeBurnIntensity / 100 : 0.25;
|
||||
operation = 'screen';
|
||||
color = 'rgba(255,255,255,1)';
|
||||
alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));
|
||||
} else if (tool === 'burn') {
|
||||
// Darken the pixels already there rather than stamping flat black.
|
||||
const intensity = Number.isFinite(state.dodgeBurnIntensity) ? state.dodgeBurnIntensity / 100 : 0.25;
|
||||
operation = 'multiply';
|
||||
color = 'rgba(0,0,0,1)';
|
||||
alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));
|
||||
}
|
||||
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
|
||||
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
|
||||
if (state.brushPixel && (tool === 'brush' || tool === 'eraser') && !target.paintingMask) {
|
||||
// The Pixel tool always stamps hard squares; the bundle flag makes a
|
||||
// regular Brush/Eraser do the same when the (legacy) pixel preset is on.
|
||||
if ((tool === 'pixel' || (state.brushPixel && (tool === 'brush' || tool === 'eraser'))) && !target.paintingMask) {
|
||||
// Square pixel brush: hard integer-aligned dabs at the chosen size.
|
||||
// Freehand by default; hold Shift to constrain the stroke to the
|
||||
// nearest of 8 directions (a straight line anchored at the stroke
|
||||
|
||||
@@ -76,16 +76,42 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
if (preview) preview.style.opacity = (state.smudgeStrength / 100).toFixed(2);
|
||||
});
|
||||
|
||||
const dodgeBurnIntensity = document.getElementById('ge-dodgeburn-intensity');
|
||||
dodgeBurnIntensity?.addEventListener('input', (e) => {
|
||||
state.dodgeBurnIntensity = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-dodgeburn-intensity-label').textContent = `${state.dodgeBurnIntensity}%`;
|
||||
const preview = document.getElementById('ge-dodgeburn-preview');
|
||||
if (preview) preview.style.opacity = (0.2 + state.dodgeBurnIntensity / 125).toFixed(2);
|
||||
});
|
||||
|
||||
const spacing = document.getElementById('ge-brush-spacing');
|
||||
const smoothing = document.getElementById('ge-brush-smoothing');
|
||||
const blend = document.getElementById('ge-brush-blend');
|
||||
const spacingPrev = document.getElementById('ge-brush-preview-spacing');
|
||||
const smoothingPrev = document.getElementById('ge-brush-preview-smoothing');
|
||||
// Spacing preview: split the single dot into two dots whose gap grows with
|
||||
// the spacing value, so the swatch reads as "dots spaced apart".
|
||||
const paintSpacingPreview = () => {
|
||||
if (!spacingPrev) return;
|
||||
const gap = Math.min(9, 1 + state.brushSpacing * 0.08);
|
||||
const c1 = (7 - gap / 2).toFixed(2);
|
||||
const c2 = (7 + gap / 2).toFixed(2);
|
||||
spacingPrev.style.opacity = '';
|
||||
spacingPrev.style.background = `radial-gradient(circle 3.4px at ${c1}px 50%, var(--fg) 98%, transparent 100%), radial-gradient(circle 3.4px at ${c2}px 50%, var(--fg) 98%, transparent 100%)`;
|
||||
};
|
||||
spacing?.addEventListener('input', (e) => {
|
||||
state.brushSpacing = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
|
||||
paintSpacingPreview();
|
||||
});
|
||||
smoothing?.addEventListener('input', (e) => {
|
||||
state.brushSmoothing = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
|
||||
// More smoothing → softer edge; widen the radial falloff.
|
||||
if (smoothingPrev) {
|
||||
const innerStop = Math.max(0, 70 - state.brushSmoothing * 0.7);
|
||||
smoothingPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
}
|
||||
});
|
||||
blend?.addEventListener('change', (e) => { state.brushBlendMode = e.target.value; });
|
||||
document.getElementById('ge-pressure-size')?.addEventListener('change', e => { state.pressureSize = e.target.checked; });
|
||||
@@ -99,11 +125,28 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
|
||||
let presets = loadBrushPresets();
|
||||
const presetSelect = document.getElementById('ge-brush-preset');
|
||||
const presetSaveBtn = document.getElementById('ge-brush-preset-save');
|
||||
const presetDeleteBtn = document.getElementById('ge-brush-preset-delete');
|
||||
// Save and Delete only apply to custom brushes: they are hidden while a
|
||||
// fixed built-in preset is selected. Save is available on the unsaved
|
||||
// "Custom" slot (to create one) and on a saved custom (to save over it);
|
||||
// Delete is available only for a saved (non built-in) custom preset.
|
||||
const syncPresetButtons = () => {
|
||||
const id = presetSelect?.value || '';
|
||||
const preset = presets.find(item => item.id === id);
|
||||
const isBuiltIn = !!preset && !!preset.builtIn;
|
||||
const isUserCustom = !!preset && !preset.builtIn;
|
||||
if (presetSaveBtn) presetSaveBtn.hidden = isBuiltIn;
|
||||
if (presetDeleteBtn) presetDeleteBtn.hidden = !isUserCustom;
|
||||
};
|
||||
const renderPresets = (selectedId = '') => {
|
||||
if (!presetSelect) return;
|
||||
presetSelect.innerHTML = '<option value="">Brush presets</option>' + presets.map(preset =>
|
||||
// Pixel is its own tool, so it is not offered as a brush preset here.
|
||||
const list = presets.filter(preset => !preset.pixel);
|
||||
presetSelect.innerHTML = '<option value="">Custom</option>' + list.map(preset =>
|
||||
`<option value="${preset.id}">${preset.name}</option>`).join('');
|
||||
presetSelect.value = selectedId;
|
||||
syncPresetButtons();
|
||||
};
|
||||
const syncControls = () => {
|
||||
if (spacing) spacing.value = String(state.brushSpacing);
|
||||
@@ -114,14 +157,28 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
if (smudgeLabel) smudgeLabel.textContent = `${state.smudgeStrength}%`;
|
||||
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
|
||||
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
|
||||
paintSpacingPreview();
|
||||
onPresetApplied?.();
|
||||
};
|
||||
renderPresets();
|
||||
// Restore the last preset the user picked instead of snapping back to the
|
||||
// unsaved "Custom" slot on every editor open.
|
||||
let savedPresetId = '';
|
||||
try { savedPresetId = localStorage.getItem('ge-brush-preset') || ''; } catch (error) { /* storage unavailable */ }
|
||||
renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');
|
||||
presetSelect?.addEventListener('change', () => {
|
||||
try { localStorage.setItem('ge-brush-preset', presetSelect.value || ''); } catch (error) { /* ignore */ }
|
||||
const preset = presets.find(item => item.id === presetSelect.value);
|
||||
if (!preset) return;
|
||||
if (!preset) {
|
||||
// "Custom" (no preset) — leave the current brush alone but drop pixel
|
||||
// mode so the user can get back out of the 1px Pixel preset.
|
||||
state.brushPixel = false;
|
||||
onPresetApplied?.();
|
||||
syncPresetButtons();
|
||||
return;
|
||||
}
|
||||
applyBrushPreset(state, preset);
|
||||
syncControls();
|
||||
syncPresetButtons();
|
||||
});
|
||||
document.getElementById('ge-brush-preset-save')?.addEventListener('click', () => {
|
||||
const name = window.prompt('Brush preset name');
|
||||
@@ -129,6 +186,7 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
const preset = captureBrushPreset(state, name);
|
||||
presets.push(preset);
|
||||
saveCustomBrushPresets(presets);
|
||||
try { localStorage.setItem('ge-brush-preset', preset.id); } catch (error) { /* ignore */ }
|
||||
renderPresets(preset.id);
|
||||
});
|
||||
document.getElementById('ge-brush-preset-delete')?.addEventListener('click', () => {
|
||||
@@ -136,6 +194,35 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
if (!preset || preset.builtIn) return;
|
||||
presets = presets.filter(item => item.id !== preset.id);
|
||||
saveCustomBrushPresets(presets);
|
||||
renderPresets();
|
||||
try { localStorage.setItem('ge-brush-preset', ''); } catch (error) { /* ignore */ }
|
||||
renderPresets();
|
||||
paintSpacingPreview();
|
||||
});
|
||||
|
||||
// The caret ("v") next to the brush row drops down the less-used stroke
|
||||
// controls (Spacing / Smoothing / Blend / pen pressure) so the options strip
|
||||
// stays a single tidy line. Same pattern as the wand's caret menu.
|
||||
const moreBtn = document.getElementById('ge-brush-more');
|
||||
const moreMenu = document.getElementById('ge-brush-more-menu');
|
||||
const closeBrushMore = () => {
|
||||
if (!moreMenu || moreMenu.hidden) return;
|
||||
moreMenu.hidden = true;
|
||||
moreBtn?.setAttribute('aria-expanded', 'false');
|
||||
moreBtn?.classList.remove('active');
|
||||
};
|
||||
moreBtn?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const open = moreMenu.hidden;
|
||||
moreMenu.hidden = !open;
|
||||
moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
moreBtn.classList.toggle('active', open);
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!moreMenu || moreMenu.hidden) return;
|
||||
if (moreMenu.contains(event.target) || moreBtn?.contains(event.target)) return;
|
||||
closeBrushMore();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeBrushMore();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
let input = null;
|
||||
let layer = null;
|
||||
let resizeObserver = null;
|
||||
let handle = null;
|
||||
let frame = 0;
|
||||
let appliedWidth = 0;
|
||||
let resizing = false;
|
||||
|
||||
function position() {
|
||||
if (!input || !layer || !state.mainCanvas?.isConnected) return;
|
||||
@@ -20,13 +23,27 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
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`;
|
||||
// Only rewrite the width when the model's frame width (or the zoom) changed.
|
||||
// Writing it every animation frame overwrote the native resize handle's
|
||||
// width before the ResizeObserver could report it, so the box could not be
|
||||
// resized at all.
|
||||
const targetW = Math.max(80, text.frameWidth * scaleX);
|
||||
if (!resizing && Math.abs(targetW - appliedWidth) > 0.5) {
|
||||
input.style.width = `${targetW}px`;
|
||||
appliedWidth = targetW;
|
||||
}
|
||||
input.style.minWidth = '40px';
|
||||
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;
|
||||
if (handle) {
|
||||
const rect = input.getBoundingClientRect();
|
||||
handle.style.left = `${rect.right - 8}px`;
|
||||
handle.style.top = `${rect.bottom - 8}px`;
|
||||
handle.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
@@ -39,6 +56,9 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
const value = input.value;
|
||||
resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
handle?.remove();
|
||||
handle = null;
|
||||
resizing = false;
|
||||
input.remove();
|
||||
input = null;
|
||||
const target = layer;
|
||||
@@ -49,6 +69,7 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
function open(target, { selectAll = false } = {}) {
|
||||
close(true);
|
||||
layer = target;
|
||||
appliedWidth = 0;
|
||||
input = document.createElement('textarea');
|
||||
input.className = 'ge-direct-text-editor';
|
||||
input.value = target.text.content;
|
||||
@@ -67,9 +88,51 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
});
|
||||
let ready = false;
|
||||
input.addEventListener('blur', () => setTimeout(() => {
|
||||
if (ready && input && document.activeElement !== input) close(true);
|
||||
if (ready && input && !resizing && document.activeElement !== input) close(true);
|
||||
}, 0));
|
||||
document.body.appendChild(input);
|
||||
// A visible grab square in the corner: the native textarea resize grip is
|
||||
// invisible in this theme and only a few pixels wide, so most drags missed
|
||||
// it. This handle also survives the animation-frame positioning pass.
|
||||
handle = document.createElement('div');
|
||||
handle.className = 'ge-direct-text-handle';
|
||||
handle.title = 'Drag to resize';
|
||||
handle.setAttribute('aria-hidden', 'true');
|
||||
handle.style.display = 'none';
|
||||
// Same dot-grip language as the layer-row drag handles, arranged as a
|
||||
// corner wedge so it reads as "resize from this corner".
|
||||
handle.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><circle cx="4" cy="9" r="1.1"/><circle cx="7" cy="9" r="1.1"/><circle cx="10" cy="9" r="1.1"/><circle cx="7" cy="6" r="1.1"/><circle cx="10" cy="6" r="1.1"/><circle cx="10" cy="3" r="1.1"/></svg>';
|
||||
let dragStart = null;
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (!input || !layer || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
const scale = canvasRect.width / Math.max(1, state.mainCanvas.width);
|
||||
const rect = input.getBoundingClientRect();
|
||||
dragStart = { x: event.clientX, y: event.clientY, w: rect.width, h: rect.height, scale };
|
||||
resizing = true;
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
handle.addEventListener('pointermove', (event) => {
|
||||
if (!dragStart || !input || !layer) return;
|
||||
const w = Math.max(80, Math.round(dragStart.w + (event.clientX - dragStart.x)));
|
||||
const h = Math.max(36, Math.round(dragStart.h + (event.clientY - dragStart.y)));
|
||||
input.style.width = `${w}px`;
|
||||
input.style.height = `${h}px`;
|
||||
appliedWidth = w;
|
||||
onInput?.(layer, input.value, input, w / Math.max(0.01, dragStart.scale));
|
||||
});
|
||||
const endDrag = () => {
|
||||
if (!dragStart) return;
|
||||
dragStart = null;
|
||||
resizing = false;
|
||||
if (input) appliedWidth = input.getBoundingClientRect().width;
|
||||
input?.focus({ preventScroll: true });
|
||||
};
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', endDrag);
|
||||
document.body.appendChild(handle);
|
||||
resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => {
|
||||
if (!input || !layer) return;
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
|
||||
@@ -35,6 +35,7 @@ export const WARP_STYLES = Object.freeze([
|
||||
system extras but are filtered out at runtime when unavailable. */
|
||||
export const EDITOR_TEXT_FONTS = Object.freeze([
|
||||
'Inter', 'Roboto', 'Playfair Display', 'Lobster', 'Bebas Neue', 'Roboto Mono',
|
||||
'Space Grotesk', 'Fraunces', 'Syne', 'Bungee', 'Instrument Serif',
|
||||
'Georgia', 'Courier New',
|
||||
]);
|
||||
|
||||
@@ -42,9 +43,9 @@ export const EDITOR_TEXT_FONTS = Object.freeze([
|
||||
re-render can pick up faces that were not yet loaded on first paint. */
|
||||
export function loadEditorFonts() {
|
||||
if (typeof document === 'undefined' || !document.fonts || !document.fonts.load) return Promise.resolve([]);
|
||||
return Promise.all(EDITOR_TEXT_FONTS.map((family) => {
|
||||
try { return document.fonts.load(`400 48px "${family}"`).catch(() => []); } catch (_) { return Promise.resolve([]); }
|
||||
})).catch(() => []);
|
||||
return Promise.all(EDITOR_TEXT_FONTS.flatMap((family) => ['400', '700'].map((weight) => {
|
||||
try { return document.fonts.load(`${weight} 48px "${family}"`).catch(() => []); } catch (_) { return Promise.resolve([]); }
|
||||
}))).catch(() => []);
|
||||
}
|
||||
|
||||
/* Only offer families the browser can actually render distinctly. */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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',
|
||||
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O', liquify: 'K',
|
||||
marquee: 'M', lasso: 'L', wand: 'W', pen: 'P',
|
||||
zoom: 'Z',
|
||||
});
|
||||
|
||||
@@ -127,9 +127,27 @@ export function createBucketTool({ activeLayer, saveState, composite, renderLaye
|
||||
return count;
|
||||
}
|
||||
|
||||
// The active layer can be an adjustment layer (no raster canvas) or a
|
||||
// hidden/locked one; in that case fall back to the topmost visible raster
|
||||
// layer so a click still fills something instead of silently doing nothing.
|
||||
function paintableLayer() {
|
||||
const direct = activeLayer();
|
||||
if (direct?.canvas && direct.ctx && direct.kind !== 'adjustment') return direct;
|
||||
for (let i = state.layers.length - 1; i >= 0; i--) {
|
||||
const l = state.layers[i];
|
||||
if (l?.canvas && l.ctx && l.kind !== 'adjustment' && l.visible !== false) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fill(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer?.canvas || !layer.ctx) return 0;
|
||||
const layer = paintableLayer();
|
||||
if (!layer) { showToast?.('Select a pixel layer to fill'); return 0; }
|
||||
if (state.activeLayerId !== layer.id) {
|
||||
state.activeLayerId = layer.id;
|
||||
state.selectedLayerIds = [layer.id];
|
||||
renderLayerPanel?.();
|
||||
}
|
||||
const point = canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* showCropApply: () => void,
|
||||
* syncCropFrame?: () => void,
|
||||
* snapFrame?: (frame: object, center: object) => object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
@@ -19,7 +21,7 @@ import { canvasCoords } from '../canvas-coords.js';
|
||||
import { drawCheckerboard } from '../checkerboard.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
export function createCropTool({ composite, showCropApply, snapFrame, syncCropFrame }) {
|
||||
const gesture = createDirectManipulationSession({
|
||||
name: 'crop',
|
||||
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
|
||||
@@ -96,22 +98,26 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
} 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));
|
||||
// Allow the rect to be dragged beyond the image edge as well (that
|
||||
// expands the canvas on Apply). Only guard against absurd coords.
|
||||
const MAX = 20000;
|
||||
nx = Math.max(-MAX, Math.min(nx, MAX));
|
||||
ny = Math.max(-MAX, Math.min(ny, MAX));
|
||||
state.cropRect = { ...state.cropRect, x: nx, y: ny };
|
||||
composite();
|
||||
syncCropFrame?.();
|
||||
return;
|
||||
}
|
||||
if (!state.cropping) return;
|
||||
e.preventDefault();
|
||||
// Clamp the dragged corner to the document so a pointer that
|
||||
// overshoots onto a panel (or past the canvas edge) still yields a
|
||||
// rect inside the image rather than negative/off-canvas coords.
|
||||
// The dragged corner may go OUTSIDE the image: a rect that reaches
|
||||
// past an edge expands the canvas there (transparent fill) on Apply.
|
||||
// Only clamp against absurd coordinates (pointer flung off-screen).
|
||||
const end = canvasCoords(e, state.mainCanvas);
|
||||
const MAX = 20000;
|
||||
state.cropEnd = {
|
||||
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
|
||||
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
|
||||
x: Math.max(-MAX, Math.min(MAX, end.x)),
|
||||
y: Math.max(-MAX, Math.min(MAX, end.y)),
|
||||
};
|
||||
// Aspect lock. Shift-held snapshots the current aspect on first press
|
||||
// and stays locked until released. Otherwise a chosen crop preset
|
||||
@@ -187,6 +193,7 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
state.mainCtx.strokeRect(x, y, w, h);
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.cropRect = { x, y, w, h };
|
||||
syncCropFrame?.();
|
||||
},
|
||||
|
||||
end(e) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
// Holding Alt over these tools borrows the eyedropper (Photoshop parity).
|
||||
// Clone/Heal are excluded: Alt already sets their clone source.
|
||||
export const ALT_EYEDROPPER_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'gradient', 'bucket']);
|
||||
|
||||
function hexByte(value) {
|
||||
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -60,11 +64,11 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
|
||||
return {
|
||||
preview(e) {
|
||||
const result = sample(e);
|
||||
syncPreview?.(result?.color || null, result);
|
||||
syncPreview?.(result?.color || null, result, e);
|
||||
return result?.color || null;
|
||||
},
|
||||
clearPreview() {
|
||||
syncPreview?.(null, null);
|
||||
syncPreview?.(null, null, null);
|
||||
},
|
||||
pick(e) {
|
||||
const result = sample(e, true);
|
||||
@@ -75,7 +79,7 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
|
||||
}
|
||||
state.color = color;
|
||||
syncColor?.(state.color);
|
||||
syncPreview?.(state.color, result);
|
||||
syncPreview?.(state.color, result, e);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
/**
|
||||
* Liquify — Photoshop-style destructive warp brush for the active layer.
|
||||
*
|
||||
* Dragging samples the layer's pixels through a displacement field: Push
|
||||
* drags pixels with the pointer, Twirl rotates them around the dab, Pucker
|
||||
* pulls inward, Bloat pushes outward and Smooth softens the area. Each
|
||||
* stroke is one undo step (the snapshot is taken on press) and the layer is
|
||||
* re-composited at most once per animation frame, so the canvas shows the
|
||||
* warp live without re-rendering the document on every mouse event.
|
||||
*/
|
||||
|
||||
const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);
|
||||
|
||||
export function createLiquifyTool({ activeLayer, saveState, composite }) {
|
||||
let layer = null, owner = null, last = null, live = false, raf = 0;
|
||||
|
||||
const radius = () => Math.max(4, Math.min(1000, Number(state.liquifySize) || 120));
|
||||
const strength = () => Math.max(1, Math.min(100, Number(state.liquifyStrength) || 50)) / 100;
|
||||
const mode = () => (MODES.has(state.liquifyMode) ? state.liquifyMode : 'push');
|
||||
|
||||
const requestComposite = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => { raf = 0; composite(); });
|
||||
};
|
||||
|
||||
// Pointer position in the active layer's own pixel space (layers can be
|
||||
// offset from the document origin).
|
||||
function layerPoint(event) {
|
||||
const p = canvasCoords(event, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
return { x: p.x - off.x, y: p.y - off.y };
|
||||
}
|
||||
|
||||
function sampleBilinear(src, w, h, fx, fy, dst, di) {
|
||||
const x = Math.max(0, Math.min(w - 1, fx - 0.5));
|
||||
const y = Math.max(0, Math.min(h - 1, fy - 0.5));
|
||||
const x1 = Math.floor(x), y1 = Math.floor(y);
|
||||
const x2 = Math.min(w - 1, x1 + 1), y2 = Math.min(h - 1, y1 + 1);
|
||||
const tx = x - x1, ty = y - y1;
|
||||
const i11 = (y1 * w + x1) * 4, i21 = (y1 * w + x2) * 4;
|
||||
const i12 = (y2 * w + x1) * 4, i22 = (y2 * w + x2) * 4;
|
||||
const wx1 = 1 - tx, wy1 = 1 - ty, wx2 = tx, wy2 = ty;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
dst[di + c] = src[i11 + c] * wx1 * wy1 + src[i21 + c] * wx2 * wy1
|
||||
+ src[i12 + c] * wx1 * wy2 + src[i22 + c] * wx2 * wy2;
|
||||
}
|
||||
}
|
||||
|
||||
function average3x3(src, w, h, px, py) {
|
||||
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
||||
for (let oy = -1; oy <= 1; oy++) {
|
||||
for (let ox = -1; ox <= 1; ox++) {
|
||||
const x = Math.max(0, Math.min(w - 1, Math.round(px) + ox));
|
||||
const y = Math.max(0, Math.min(h - 1, Math.round(py) + oy));
|
||||
const i = (y * w + x) * 4;
|
||||
r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3]; n++;
|
||||
}
|
||||
}
|
||||
return { r: r / n, g: g / n, b: b / n, a: a / n };
|
||||
}
|
||||
|
||||
function dab(cx, cy, dx, dy, spin) {
|
||||
const canvas = layer.canvas, ctx = layer.ctx;
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const r = radius();
|
||||
const x0 = Math.max(0, Math.floor(cx - r - 1));
|
||||
const y0 = Math.max(0, Math.floor(cy - r - 1));
|
||||
const x1 = Math.min(w, Math.ceil(cx + r + 1));
|
||||
const y1 = Math.min(h, Math.ceil(cy + r + 1));
|
||||
const bw = x1 - x0, bh = y1 - y0;
|
||||
if (bw <= 0 || bh <= 0) return;
|
||||
|
||||
const img = ctx.getImageData(x0, y0, bw, bh);
|
||||
const out = ctx.createImageData(bw, bh);
|
||||
const src = img.data, dst = out.data;
|
||||
const m = mode(), s = strength();
|
||||
|
||||
for (let y = 0; y < bh; y++) {
|
||||
for (let x = 0; x < bw; x++) {
|
||||
const di = (y * bw + x) * 4;
|
||||
const px = x0 + x + 0.5, py = y0 + y + 0.5;
|
||||
const ox = px - cx, oy = py - cy;
|
||||
const dist = Math.hypot(ox, oy);
|
||||
if (dist >= r) {
|
||||
dst[di] = src[di]; dst[di + 1] = src[di + 1]; dst[di + 2] = src[di + 2]; dst[di + 3] = src[di + 3];
|
||||
continue;
|
||||
}
|
||||
const t = 1 - dist / r;
|
||||
const fall = t * t * (3 - 2 * t);
|
||||
let sx = px, sy = py;
|
||||
if (m === 'push') {
|
||||
sx = px - dx * fall * s;
|
||||
sy = py - dy * fall * s;
|
||||
} else if (m === 'twirl') {
|
||||
const ang = spin * s * 2.6 * fall * fall;
|
||||
const ca = Math.cos(ang), sa = Math.sin(ang);
|
||||
sx = cx + ox * ca - oy * sa;
|
||||
sy = cy + ox * sa + oy * ca;
|
||||
} else if (m === 'pucker') {
|
||||
const k = 1 - 0.55 * s * fall;
|
||||
sx = cx + ox * k;
|
||||
sy = cy + oy * k;
|
||||
} else if (m === 'bloat') {
|
||||
const k = 1 + 0.55 * s * fall;
|
||||
sx = cx + ox * k;
|
||||
sy = cy + oy * k;
|
||||
} else if (m === 'smooth') {
|
||||
const avg = average3x3(src, bw, bh, px - x0, py - y0);
|
||||
const k = fall * s * 0.9;
|
||||
dst[di] = src[di] * (1 - k) + avg.r * k;
|
||||
dst[di + 1] = src[di + 1] * (1 - k) + avg.g * k;
|
||||
dst[di + 2] = src[di + 2] * (1 - k) + avg.b * k;
|
||||
dst[di + 3] = src[di + 3] * (1 - k) + avg.a * k;
|
||||
continue;
|
||||
}
|
||||
sampleBilinear(src, bw, bh, sx - x0, sy - y0, dst, di);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(out, x0, y0);
|
||||
}
|
||||
|
||||
function strokeTo(point, previous) {
|
||||
const dx = point.x - previous.x, dy = point.y - previous.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
// Twirl direction: the pointer's motion around the dab decides clockwise
|
||||
// vs counter-clockwise, matching how Photoshop's twirl brush feels.
|
||||
const ox = previous.x - point.x, oy = previous.y - point.y;
|
||||
const spin = (ox * dy - oy * dx) >= 0 ? 1 : -1;
|
||||
const step = Math.max(1, radius() * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(distance / step));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
dab(previous.x + (dx * i) / steps, previous.y + (dy * i) / steps, dx / steps, dy / steps, spin);
|
||||
}
|
||||
requestComposite();
|
||||
}
|
||||
|
||||
return {
|
||||
begin(event) {
|
||||
const target = activeLayer();
|
||||
if (!target || !target.canvas || !target.ctx) return;
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
owner = target;
|
||||
layer = target;
|
||||
saveState('Liquify');
|
||||
last = layerPoint(event);
|
||||
live = true;
|
||||
// A single click still warps (twirl/pucker/bloat do something useful
|
||||
// on press); Push needs movement, so it is a no-op until the drag.
|
||||
if (mode() !== 'push') { dab(last.x, last.y, 0, 0, 1); requestComposite(); }
|
||||
state.drawing = true;
|
||||
},
|
||||
drag(event) {
|
||||
if (!live || !layer || owner !== activeLayer() || state.tool !== 'liquify') return;
|
||||
event.preventDefault();
|
||||
const point = layerPoint(event);
|
||||
strokeTo(point, last);
|
||||
last = point;
|
||||
},
|
||||
end() {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
last = null;
|
||||
owner = null;
|
||||
state.drawing = false;
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
composite();
|
||||
},
|
||||
cancel() {
|
||||
live = false; last = null; owner = null; state.drawing = false;
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// The stroke is sampled into positive/negative points plus a bounding
|
||||
// box, both already supported by the backend endpoint.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createObjectSelectTool({
|
||||
@@ -47,7 +48,7 @@ export function createObjectSelectTool({
|
||||
active = true;
|
||||
points = [];
|
||||
box = null;
|
||||
mode = selectionModeForEvent(event, 'replace');
|
||||
mode = selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
const coords = canvasCoords(event);
|
||||
if (coords) addPoint(coords, event);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,17 @@ 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 }) {
|
||||
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi, redraw, refreshSelection }) {
|
||||
let points = [], dragging = false, owner = null, canvas = null, mode = 'replace';
|
||||
const current = () => canvas === state.mainCanvas && state.tool === 'pen' && owner === activeLayer();
|
||||
// The in-progress path lives on its own overlay canvas, so repainting it must
|
||||
// not re-composite the whole document (that is what made every pen click flash
|
||||
// the entire canvas). Fall back to a composite only if no overlay hook exists.
|
||||
const repaint = () => { if (typeof redraw === 'function') redraw(); else composite(); };
|
||||
function cancel() {
|
||||
const hadPath = points.length > 0;
|
||||
points = []; dragging = false; owner = canvas = null;
|
||||
if (hadPath) composite();
|
||||
if (hadPath) repaint();
|
||||
return hadPath;
|
||||
}
|
||||
function trace(ctx, closed) {
|
||||
@@ -27,12 +31,18 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
const ctx = mask.getContext('2d');
|
||||
trace(ctx, true);
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
saveState('Pen selection');
|
||||
saveState('Pen selection', { selectionOnly: true });
|
||||
commitSelectionMask(mask, owner, mode, 'pen', { x: 0, y: 0 });
|
||||
state.wandMaskVisible = true;
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = []; state.lassoActive = false;
|
||||
cancel(); syncSelectionUi();
|
||||
// Only the selection changed: repainting the marching ants is enough, so
|
||||
// skip the full document composite (which is the most expensive part of
|
||||
// closing a path on a large canvas).
|
||||
cancel();
|
||||
if (typeof refreshSelection === 'function') refreshSelection();
|
||||
else composite();
|
||||
syncSelectionUi();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
@@ -50,14 +60,14 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
mode = selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
}
|
||||
points.push({ ...p, dx: 0, dy: 0 }); dragging = true;
|
||||
composite();
|
||||
repaint();
|
||||
},
|
||||
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();
|
||||
repaint();
|
||||
},
|
||||
end() { dragging = false; },
|
||||
key(event) {
|
||||
@@ -65,19 +75,48 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
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();
|
||||
points.pop(); dragging = false; repaint();
|
||||
} else return false;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); return true;
|
||||
},
|
||||
draw() {
|
||||
if (!points.length || !current()) return;
|
||||
const ctx = state.mainCtx, unit = 1 / state.zoom;
|
||||
const overlay = state.penOverlay, ctx = state.penOverlayCtx;
|
||||
if (!overlay || !ctx) return;
|
||||
if (!points.length || !current()) {
|
||||
if (overlay.style.display !== 'none') {
|
||||
overlay.style.display = 'none';
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const main = state.mainCanvas;
|
||||
const w = Math.max(1, Math.round(state.imgWidth * state.zoom));
|
||||
const h = Math.max(1, Math.round(state.imgHeight * state.zoom));
|
||||
if (overlay.width !== w || overlay.height !== h) { overlay.width = w; overlay.height = h; }
|
||||
overlay.style.width = w + 'px';
|
||||
overlay.style.height = h + 'px';
|
||||
overlay.style.left = main.offsetLeft + 'px';
|
||||
overlay.style.top = main.offsetTop + 'px';
|
||||
overlay.style.transform = main.style.transform || '';
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// The stylesheet hides this overlay by default, so clearing the inline
|
||||
// value would leave it at display:none; show it explicitly.
|
||||
overlay.style.display = 'block';
|
||||
const unit = 1 / state.zoom;
|
||||
ctx.save();
|
||||
ctx.scale(state.zoom, state.zoom);
|
||||
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) {
|
||||
// Photoshop only shows the bezier handles of the anchor being shaped.
|
||||
// Drawing them for every point turned the preview into a mesh of long
|
||||
// straight lines across the shape.
|
||||
const shaped = dragging ? points.length - 1 : -1;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const p = points[i];
|
||||
if (i === shaped && (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);
|
||||
@@ -88,6 +127,15 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
|
||||
ctx.strokeStyle = '#000'; ctx.strokeRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
|
||||
ctx.strokeStyle = '#fff';
|
||||
}
|
||||
// Ring the starting anchor once the path can be closed, so it reads as
|
||||
// "click here to close" instead of just another point.
|
||||
if (points.length >= 3) {
|
||||
const first = points[0];
|
||||
ctx.beginPath(); ctx.arc(first.x, first.y, 6 * unit, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff';
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// stroke; intermediate samples skip the undo snapshot (retune mode) so a
|
||||
// whole drag collapses to one history entry (taken on begin).
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createQuickSelectTool({
|
||||
@@ -16,13 +17,16 @@ export function createQuickSelectTool({
|
||||
}) {
|
||||
let painting = false;
|
||||
let last = null;
|
||||
let strokeMode = 'replace';
|
||||
|
||||
const modeFor = (event) => selectionModeForEvent(event, 'add');
|
||||
// Honour the New / Add / Subtract mode toggle (the wand's segmented
|
||||
// control); Shift/Alt still override for the duration of the dab.
|
||||
const modeFor = (event) => selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
|
||||
const sample = (event) => {
|
||||
const sample = (event, mode) => {
|
||||
const coords = canvasCoords(event);
|
||||
if (!coords) return;
|
||||
runMagicWand(coords.x, coords.y, modeFor(event), { retune: true });
|
||||
runMagicWand(coords.x, coords.y, mode || modeFor(event), { retune: true });
|
||||
};
|
||||
|
||||
const begin = (event) => {
|
||||
@@ -31,7 +35,8 @@ export function createQuickSelectTool({
|
||||
saveState();
|
||||
painting = true;
|
||||
last = null;
|
||||
sample(event);
|
||||
strokeMode = modeFor(event);
|
||||
sample(event, strokeMode);
|
||||
const coords = canvasCoords(event);
|
||||
if (coords) last = coords;
|
||||
};
|
||||
@@ -42,7 +47,9 @@ export function createQuickSelectTool({
|
||||
if (!coords) return;
|
||||
const gap = Math.max(3, (brushSize?.() || 24) / 4);
|
||||
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < gap) return;
|
||||
sample(event);
|
||||
// Once the stroke has replaced the old selection, later dabs must
|
||||
// accumulate onto it instead of wiping the stroke's progress.
|
||||
sample(event, strokeMode === 'replace' ? 'add' : selectionModeForEvent(event, strokeMode));
|
||||
last = coords;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ 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', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
const STROKE_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function strokeLabel(tool) {
|
||||
if (tool === 'brush') return 'Brush stroke';
|
||||
if (tool === 'pixel') return 'Pixel stroke';
|
||||
if (tool === 'eraser') return 'Eraser stroke';
|
||||
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
|
||||
if (tool === 'smudge') return 'Smudge stroke';
|
||||
@@ -72,11 +73,11 @@ export function createStrokeTool({
|
||||
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') {
|
||||
if (['brush', 'pixel', '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)) {
|
||||
if (['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
|
||||
showToast?.('Unlock image pixels before painting');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { resizeTransformFrame } from '../transform-frame-geometry.js';
|
||||
import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createTransformDragTool({
|
||||
@@ -67,6 +67,9 @@ export function createTransformDragTool({
|
||||
state.transformStartRotation = state.transformPendingRot;
|
||||
state.transformStartFlipH = state.transformPendingFlipH;
|
||||
state.transformStartFlipV = state.transformPendingFlipV;
|
||||
state.transformStartSkewX = state.transformPendingSkewX || 0;
|
||||
state.transformStartSkewY = state.transformPendingSkewY || 0;
|
||||
state.transformStartDistort = state.transformPendingDistort || null;
|
||||
state.transformStartOffX = center.x - state.transformOrigW / 2;
|
||||
state.transformStartOffY = center.y - state.transformOrigH / 2;
|
||||
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
|
||||
@@ -156,6 +159,57 @@ export function createTransformDragTool({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Ctrl/Cmd + drag a handle = FREE DISTORT (Photoshop). Only the
|
||||
// dragged point moves: a corner handle moves that corner alone
|
||||
// (the frame becomes a quad); an edge handle slides that edge's
|
||||
// two corners together. Size, centre, rotation and skew are left
|
||||
// untouched — distort is a separate slot on the transform target.
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const distorted = distortTransformFrame({
|
||||
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, {
|
||||
startDistort: state.transformStartDistort || null,
|
||||
});
|
||||
if (distorted) {
|
||||
state.transformPendingDistort = distorted;
|
||||
reapplyTransform();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd with no distortable handle (rotation knob) keeps the old
|
||||
// edge-shear skew so the popup's Skew X / Skew Y fields stay live.
|
||||
if ((e.ctrlKey || e.metaKey) && state.transformHandle === 'rot') {
|
||||
const skewed = skewTransformFrame({
|
||||
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, {
|
||||
startSkewX: state.transformStartSkewX || 0,
|
||||
startSkewY: state.transformStartSkewY || 0,
|
||||
});
|
||||
state.transformPendingSkewX = skewed.skewX;
|
||||
state.transformPendingSkewY = skewed.skewY;
|
||||
reapplyTransform();
|
||||
if (state.transformPopup) {
|
||||
const skewXIn = state.transformPopup.querySelector('#ge-transform-skew-x');
|
||||
const skewYIn = state.transformPopup.querySelector('#ge-transform-skew-y');
|
||||
if (skewXIn) skewXIn.value = String(Math.round(skewed.skewX));
|
||||
if (skewYIn) skewYIn.value = String(Math.round(skewed.skewY));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 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({
|
||||
|
||||
@@ -30,16 +30,43 @@ import {
|
||||
* Position the transform overlay canvas + size its backing bitmap.
|
||||
* 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.
|
||||
*
|
||||
* `extents` (image px, optional) is an extra rectangle the overlay must
|
||||
* also cover — used so the skeleton frame + handles stay inside the
|
||||
* bitmap once the frame is scaled/moved OUTSIDE the image bounds. When
|
||||
* it is omitted the overlay only covers the image (+margin), matching
|
||||
* the original behaviour. Returns the overlay's top-left in image
|
||||
* coordinates (the draw translate origin), or null when hidden.
|
||||
*/
|
||||
export function syncOverlay(margin) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return;
|
||||
export function syncOverlay(margin, extents = null) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return null;
|
||||
if (!state.transformActive) {
|
||||
state.transformOverlay.style.display = 'none';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
const imgW = state.mainCanvas.width;
|
||||
const imgH = state.mainCanvas.height;
|
||||
// Union of the image and any extra extent the frame reaches, plus the
|
||||
// handle slack on every side. Cap the bitmap so an extreme pending
|
||||
// size can never allocate a runaway canvas.
|
||||
const MAX_BITMAP = 8192;
|
||||
let left = Math.min(0, extents?.left ?? 0) - imageMargin;
|
||||
let top = Math.min(0, extents?.top ?? 0) - imageMargin;
|
||||
let right = Math.max(imgW, extents?.right ?? imgW) + imageMargin;
|
||||
let bottom = Math.max(imgH, extents?.bottom ?? imgH) + imageMargin;
|
||||
const centerX = (left + right) / 2;
|
||||
const centerY = (top + bottom) / 2;
|
||||
if (right - left > MAX_BITMAP) {
|
||||
left = centerX - MAX_BITMAP / 2;
|
||||
right = centerX + MAX_BITMAP / 2;
|
||||
}
|
||||
if (bottom - top > MAX_BITMAP) {
|
||||
top = centerY - MAX_BITMAP / 2;
|
||||
bottom = centerY + MAX_BITMAP / 2;
|
||||
}
|
||||
const W = Math.ceil(right - left);
|
||||
const H = Math.ceil(bottom - top);
|
||||
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
|
||||
@@ -53,15 +80,35 @@ export function syncOverlay(margin) {
|
||||
state.transformOverlay.style.pointerEvents = 'none';
|
||||
state.transformOverlay.style.zIndex = '5';
|
||||
// Position the overlay at the main canvas's LAYOUT position
|
||||
// (offsetLeft/Top — unaffected by CSS transforms), shifted up-left by
|
||||
// the overlay's `margin` image-px of handle slack. Then SHARE the
|
||||
// canvas's transform (the pan handler writes the same translate3d to
|
||||
// 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) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
|
||||
// (offsetLeft/Top — unaffected by CSS transforms), shifted by the
|
||||
// covered extent in screen px. Then SHARE the canvas's transform (the
|
||||
// pan handler writes the same translate3d to 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 + left * state.zoom) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop + top * state.zoom) + 'px';
|
||||
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/** Image-space AABB of the frame (incl. rotation knob + skew) or null. */
|
||||
function frameExtents(geometry) {
|
||||
if (!geometry) return null;
|
||||
const points = [
|
||||
geometry.corners?.tl, geometry.corners?.tr,
|
||||
geometry.corners?.br, geometry.corners?.bl,
|
||||
geometry.rotationHandle,
|
||||
].filter(Boolean);
|
||||
if (!points.length) return null;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
return { left: minX, top: minY, right: maxX, bottom: maxY };
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +150,9 @@ function transformFrame() {
|
||||
width: state.transformPendingW || state.transformSelectionBounds.width,
|
||||
height: state.transformPendingH || state.transformSelectionBounds.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
skewX: state.transformPendingSkewX || 0,
|
||||
skewY: state.transformPendingSkewY || 0,
|
||||
distort: state.transformPendingDistort || null,
|
||||
};
|
||||
}
|
||||
const layer = state.transformLayer;
|
||||
@@ -118,6 +168,9 @@ function transformFrame() {
|
||||
width: state.transformPendingW || layer.canvas.width,
|
||||
height: state.transformPendingH || layer.canvas.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
skewX: state.transformPendingSkewX || 0,
|
||||
skewY: state.transformPendingSkewY || 0,
|
||||
distort: state.transformPendingDistort || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +182,6 @@ function transformFrame() {
|
||||
*/
|
||||
export function drawHandles(margin) {
|
||||
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
|
||||
syncOverlay(margin);
|
||||
if (!state.transformOverlayCtx) return;
|
||||
const frame = transformFrame();
|
||||
if (!frame) return;
|
||||
@@ -138,13 +190,18 @@ export function drawHandles(margin) {
|
||||
zoom: state.zoom,
|
||||
rotationInside: rotInside,
|
||||
});
|
||||
// Grow the overlay so the whole frame + handles fit even when the
|
||||
// frame extends well past the image bounds (otherwise the skeleton
|
||||
// gets clipped at the bitmap edge during a resize).
|
||||
const cover = syncOverlay(margin, frameExtents(geometry));
|
||||
if (!cover) return;
|
||||
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).
|
||||
// Clear + shift drawing so image (cover.left, cover.top) maps to
|
||||
// overlay (0, 0).
|
||||
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
|
||||
ctx.save();
|
||||
ctx.translate(imageMargin, imageMargin);
|
||||
ctx.translate(-cover.left, -cover.top);
|
||||
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
|
||||
const sz = 10 / state.zoom;
|
||||
const stroke = 1.5 / state.zoom;
|
||||
|
||||
@@ -169,6 +169,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = 0;
|
||||
state.transformPendingPerspX = 0;
|
||||
state.transformPendingPerspY = 0;
|
||||
state.transformPendingDistort = null;
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
@@ -239,7 +240,10 @@ export function createTransformSession({
|
||||
mobileBoundsHandler = null;
|
||||
}
|
||||
if (state.transformPopup) {
|
||||
try { state.transformPopup.remove(); } catch {}
|
||||
try {
|
||||
state.transformPopup.innerHTML = '';
|
||||
state.transformPopup.style.display = 'none';
|
||||
} catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
}
|
||||
@@ -277,15 +281,16 @@ export function createTransformSession({
|
||||
// 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();
|
||||
// The transform controls live inline in the editor options strip (header
|
||||
// bar) instead of a floating popup over the canvas.
|
||||
const pop = document.getElementById('ge-transform-section');
|
||||
if (!pop) return;
|
||||
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
||||
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);
|
||||
pop.style.display = '';
|
||||
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');
|
||||
@@ -438,9 +443,6 @@ export function createTransformSession({
|
||||
});
|
||||
});
|
||||
attachSpinRepeat(pop);
|
||||
applyMobileTransformBounds(pop);
|
||||
mobileBoundsHandler = () => applyMobileTransformBounds(pop);
|
||||
window.addEventListener('resize', mobileBoundsHandler);
|
||||
}
|
||||
|
||||
// Header-drag for the Transform popup. Default position: over the
|
||||
@@ -551,6 +553,19 @@ export function createTransformSession({
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
if (geometry.rotation) ctx.rotate(geometry.radians);
|
||||
if (geometry.distort && geometry.quad) {
|
||||
// Free distort: map the quad into the output bounds and warp.
|
||||
const { points, minX, minY, quadW, quadH } = geometry.quad;
|
||||
const normalized = points.map(point => ({
|
||||
x: (point.x - minX) / quadW,
|
||||
y: (point.y - minY) / quadH,
|
||||
}));
|
||||
const warped = perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height);
|
||||
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
|
||||
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
|
||||
ctx.restore();
|
||||
return canvas;
|
||||
}
|
||||
if (isPerspectiveActive(geometry.perspectiveX, geometry.perspectiveY)) {
|
||||
const warped = perspectiveWarpCanvas(source, keystoneCorners(geometry.perspectiveX, geometry.perspectiveY), canvas.width, canvas.height);
|
||||
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
|
||||
@@ -576,6 +591,7 @@ export function createTransformSession({
|
||||
skewY: state.transformPendingSkewY,
|
||||
perspectiveX: state.transformPendingPerspX,
|
||||
perspectiveY: state.transformPendingPerspY,
|
||||
distort: state.transformPendingDistort,
|
||||
centerX: state.transformCenter?.x,
|
||||
centerY: state.transformCenter?.y,
|
||||
};
|
||||
@@ -592,6 +608,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = target.skewY || 0;
|
||||
state.transformPendingPerspX = target.perspectiveX || 0;
|
||||
state.transformPendingPerspY = target.perspectiveY || 0;
|
||||
state.transformPendingDistort = target.distort || null;
|
||||
state.transformCenter = { x: target.centerX, y: target.centerY };
|
||||
}
|
||||
|
||||
@@ -654,7 +671,12 @@ export function createTransformSession({
|
||||
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
|
||||
const layer = snapshot.layer;
|
||||
let geometry = plannedGeometry;
|
||||
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
|
||||
// A free-distort quad is a non-affine warp that placed-layer data cannot
|
||||
// express, so bake it into the raster instead of the placed source.
|
||||
const usePlaced = snapshot.placed
|
||||
&& (snapshot.wasPlaced || state.transformPreserveSource !== false)
|
||||
&& !plannedGeometry.distort;
|
||||
if (usePlaced) {
|
||||
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
geometry = {
|
||||
@@ -664,7 +686,7 @@ export function createTransformSession({
|
||||
offset: rendered.offset,
|
||||
};
|
||||
} else {
|
||||
if (snapshot.kind === 'raster') {
|
||||
if (snapshot.kind === 'raster' || plannedGeometry.distort) {
|
||||
layer.kind = 'raster';
|
||||
layer.placed = null;
|
||||
}
|
||||
@@ -791,6 +813,7 @@ export function createTransformSession({
|
||||
state.transformPendingSkewY = 0;
|
||||
state.transformPendingPerspX = 0;
|
||||
state.transformPendingPerspY = 0;
|
||||
state.transformPendingDistort = null;
|
||||
state.transformActive = false;
|
||||
state.transformTarget = null;
|
||||
state.transformLayer = null;
|
||||
|
||||
@@ -25,23 +25,56 @@ export function transformFrameGeometry(frame, options = {}) {
|
||||
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]) => ({
|
||||
// Skew (degrees) shears the frame in its LOCAL axes — the same order the
|
||||
// canvas render pipeline uses (scale → skew → rotate). Zero skew leaves
|
||||
// the geometry byte-for-byte identical to the un-skewed frame.
|
||||
const skewX = finite(frame?.skewX);
|
||||
const skewY = finite(frame?.skewY);
|
||||
const shearX = Math.tan(skewX * Math.PI / 180);
|
||||
const shearY = Math.tan(skewY * Math.PI / 180);
|
||||
const localToWorld = (localX, localY) => {
|
||||
const sx = localX + shearX * localY;
|
||||
const sy = localY + shearY * localX;
|
||||
return {
|
||||
x: centerX + sx * cos - sy * sin,
|
||||
y: centerY + sx * sin + sy * cos,
|
||||
};
|
||||
};
|
||||
// Free distort (Ctrl/Cmd+drag): per-corner offsets in frame-normal units.
|
||||
// When present, the corner/edge handles follow the quad so only the dragged
|
||||
// point moves; every other point stays where it was.
|
||||
const distort = frame?.distort || null;
|
||||
const localOf = (() => {
|
||||
if (!distort) {
|
||||
return Object.fromEntries(HANDLE_LAYOUT.map(([id, xFactor, yFactor]) => [id, { x: width * xFactor, y: height * yFactor }]));
|
||||
}
|
||||
const delta = key => distort[key] || { x: 0, y: 0 };
|
||||
const corner = (key, sx, sy) => ({
|
||||
x: (sx + delta(key).x) * width,
|
||||
y: (sy + delta(key).y) * height,
|
||||
});
|
||||
const tl = corner('tl', -0.5, -0.5);
|
||||
const tr = corner('tr', 0.5, -0.5);
|
||||
const br = corner('br', 0.5, 0.5);
|
||||
const bl = corner('bl', -0.5, 0.5);
|
||||
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||
return { tl, t: mid(tl, tr), tr, r: mid(tr, br), br, b: mid(br, bl), bl, l: mid(bl, tl) };
|
||||
})();
|
||||
const resizeHandles = HANDLE_LAYOUT.map(([id]) => ({
|
||||
id,
|
||||
...rotateLocal(width * xFactor, height * yFactor),
|
||||
...localToWorld(localOf[id].x, localOf[id].y),
|
||||
}));
|
||||
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 topMidX = localOf.t ? localOf.t.x : 0;
|
||||
const topMidY = localOf.t ? localOf.t.y : -height / 2;
|
||||
const rotationLocalY = options.rotationInside
|
||||
? -Math.max(0, height / 2 - rotationOffset)
|
||||
: -(height / 2 + rotationOffset);
|
||||
? topMidY + Math.max(0, rotationOffset)
|
||||
: topMidY - rotationOffset;
|
||||
const rotationHandle = {
|
||||
id: 'rot',
|
||||
...rotateLocal(0, rotationLocalY),
|
||||
...localToWorld(topMidX, rotationLocalY),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -51,6 +84,9 @@ export function transformFrameGeometry(frame, options = {}) {
|
||||
height,
|
||||
rotation,
|
||||
radians,
|
||||
skewX,
|
||||
skewY,
|
||||
distort,
|
||||
resizeHandles,
|
||||
handles: [...resizeHandles, rotationHandle],
|
||||
rotationHandle,
|
||||
@@ -149,3 +185,88 @@ export function resizeTransformFrame(frame, handle, startPoint, currentPoint, op
|
||||
flipV: !!options.flipV !== (rawHeight < 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skew a frame from a screen-space pointer delta on its rotated axes,
|
||||
* mirroring Photoshop's Ctrl+drag on a handle.
|
||||
*
|
||||
* top / bottom handles → horizontal drag shears vertically-offset
|
||||
* rows (skewX)
|
||||
* left / right handles → vertical drag shears horizontally-offset
|
||||
* columns (skewY)
|
||||
* corner handles → both
|
||||
*
|
||||
* Returns { skewX, skewY } in degrees relative to `options.startSkew*`,
|
||||
* clamped to ±85° so the tangent never blows up. Size, centre and
|
||||
* rotation are untouched — skew is a separate slot in the transform
|
||||
* target, exactly like the popup's Skew X / Skew Y fields.
|
||||
*/
|
||||
export function skewTransformFrame(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;
|
||||
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 toDegrees = 180 / Math.PI;
|
||||
const LIMIT = 85;
|
||||
const clamp = value => Math.max(-LIMIT, Math.min(LIMIT, value));
|
||||
const startSkewX = finite(options.startSkewX, 0);
|
||||
const startSkewY = finite(options.startSkewY, 0);
|
||||
|
||||
let skewX = startSkewX;
|
||||
let skewY = startSkewY;
|
||||
// Top/bottom edge: the dragged edge follows the horizontal delta.
|
||||
if (ySign) skewX = clamp(startSkewX + Math.atan2(2 * localDx / ySign, geometry.height) * toDegrees);
|
||||
// Left/right edge: the dragged edge follows the vertical delta.
|
||||
if (xSign) skewY = clamp(startSkewY + Math.atan2(2 * localDy / xSign, geometry.width) * toDegrees);
|
||||
|
||||
return { skewX, skewY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Free distort from a screen-space pointer delta on a handle, mirroring
|
||||
* Photoshop's Ctrl+drag: ONLY the dragged point moves.
|
||||
*
|
||||
* corner handle → that corner alone follows the pointer (the frame
|
||||
* becomes a quad)
|
||||
* edge handle → both corners of that edge move together, so the edge
|
||||
* slides/keeps its length but nothing else moves
|
||||
*
|
||||
* Corner offsets are returned in FRAME-NORMAL units (a fraction of the
|
||||
* frame's width/height; ±0.5 is the frame half-size) relative to
|
||||
* `options.startDistort`, so they compose with any existing distort.
|
||||
* Size, centre, rotation and skew are untouched.
|
||||
*/
|
||||
export function distortTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {
|
||||
const geometry = transformFrameGeometry(frame);
|
||||
const corners = ['tl', 'tr', 'br', 'bl'];
|
||||
const owners = {
|
||||
tl: ['tl'], tr: ['tr'], br: ['br'], bl: ['bl'],
|
||||
t: ['tl', 'tr'], b: ['bl', 'br'], l: ['tl', 'bl'], r: ['tr', 'br'],
|
||||
}[handle];
|
||||
if (!owners) return null;
|
||||
|
||||
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 ndx = localDx / geometry.width;
|
||||
const ndy = localDy / geometry.height;
|
||||
|
||||
const start = options.startDistort || null;
|
||||
const base = key => (start && start[key] ? { x: finite(start[key].x), y: finite(start[key].y) } : { x: 0, y: 0 });
|
||||
const LIMIT = 4;
|
||||
const clamp = value => Math.max(-LIMIT, Math.min(LIMIT, value));
|
||||
const next = {};
|
||||
for (const key of corners) {
|
||||
if (owners.includes(key)) next[key] = { x: clamp(base(key).x + ndx), y: clamp(base(key).y + ndy) };
|
||||
else next[key] = base(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
* combineShapes: (op: string) => void,
|
||||
* syncWindowChecks?: (menu: HTMLElement | null) => void,
|
||||
* togglePanel?: (id: string) => void,
|
||||
* saveWorkspace?: () => void,
|
||||
* newDocument?: (w: number, h: number) => void,
|
||||
* promptNewDocument?: () => Promise<{ w: number, h: number } | null>,
|
||||
* newFromClipboard?: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { wireSubmenuToggles } from './submenu-toggles.js';
|
||||
@@ -88,8 +92,19 @@ function wireMenuKeyboard(container) {
|
||||
});
|
||||
}
|
||||
|
||||
export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel }) {
|
||||
export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, saveWorkspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel, toggleQuickEdit, newDocument, promptNewDocument, newFromClipboard }) {
|
||||
const click = (id) => document.getElementById(id)?.click();
|
||||
// Picking a workspace is a one-shot choice, so it closes the Window menu
|
||||
// (and any open flyout) unlike the panel toggles, which keep it open.
|
||||
const closeWindowMenu = () => {
|
||||
const menu = document.getElementById('ge-window-menu');
|
||||
if (!menu) return;
|
||||
menu.querySelectorAll('.ge-menu-parent.open').forEach((parent) => {
|
||||
parent.classList.remove('open');
|
||||
parent.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
menu.hidden = true;
|
||||
};
|
||||
// A menu row may synthesise a click on an external control (Window/Edit >
|
||||
// History clicks #ge-history-btn). That click bubbles to the document and
|
||||
// would trip our own click-away handler, closing the menu mid-toggle — so
|
||||
@@ -169,16 +184,31 @@ export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, do
|
||||
const action = item.dataset.windowAction;
|
||||
if (action === 'history') click('ge-history-btn');
|
||||
else if (action === 'shortcuts') click('ge-shortcuts-btn');
|
||||
else if (action.startsWith('workspace-')) {
|
||||
else if (action === 'workspace-save') {
|
||||
saveWorkspace?.();
|
||||
closeWindowMenu();
|
||||
} else if (action.startsWith('workspace-')) {
|
||||
const name = action.slice('workspace-'.length);
|
||||
try { localStorage.setItem('ge-active-workspace', name); } catch {}
|
||||
workspace?.(name);
|
||||
} else if (WINDOW_PANEL_ACTIONS.includes(action)) togglePanel?.(action);
|
||||
closeWindowMenu();
|
||||
} else if (action === 'quick-edit') toggleQuickEdit?.();
|
||||
else if (WINDOW_PANEL_ACTIONS.includes(action)) togglePanel?.(action);
|
||||
}, () => syncWindowChecks?.(document.getElementById('ge-window-menu')), true);
|
||||
|
||||
bind('ge-file-menu-btn', 'ge-file-menu', (item) => {
|
||||
const action = item.dataset.fileAction;
|
||||
if (action === 'import') click('ge-import-topbar');
|
||||
else if (action === 'new-default') newDocument?.(1920, 1080);
|
||||
else if (action === 'new-square') newDocument?.(1080, 1080);
|
||||
else if (action === 'new-portrait') newDocument?.(1080, 1350);
|
||||
else if (action === 'new-story') newDocument?.(1080, 1920);
|
||||
else if (action === 'new-clipboard') newFromClipboard?.();
|
||||
else if (action === 'new-custom') {
|
||||
Promise.resolve(promptNewDocument?.()).then((size) => {
|
||||
if (size && size.w > 0 && size.h > 0) newDocument?.(size.w, size.h);
|
||||
});
|
||||
}
|
||||
else if (action === 'save') click('ge-save');
|
||||
else if (action === 'save-copy') click('ge-export-gallery');
|
||||
else if (action === 'export') click('ge-download');
|
||||
|
||||
@@ -168,6 +168,33 @@ export function wireSelectionControls({
|
||||
});
|
||||
document.getElementById('ge-lasso-invert')?.addEventListener('click', invertSelection);
|
||||
|
||||
// Lasso "More" dropdown holding the secondary controls (feather, edge
|
||||
// stroke, magnetic + its width/contrast) so the lasso strip stays short.
|
||||
const lassoMore = document.getElementById('ge-lasso-more');
|
||||
const lassoMoreMenu = document.getElementById('ge-lasso-more-menu');
|
||||
const closeLassoMore = () => {
|
||||
if (!lassoMoreMenu || lassoMoreMenu.hidden) return;
|
||||
lassoMoreMenu.hidden = true;
|
||||
lassoMore?.setAttribute('aria-expanded', 'false');
|
||||
lassoMore?.classList.remove('active');
|
||||
};
|
||||
lassoMore?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (!lassoMoreMenu) return;
|
||||
const open = lassoMoreMenu.hidden;
|
||||
lassoMoreMenu.hidden = !open;
|
||||
lassoMore.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
lassoMore.classList.toggle('active', open);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!lassoMoreMenu || lassoMoreMenu.hidden) return;
|
||||
if (lassoMoreMenu.contains(e.target) || e.target === lassoMore) return;
|
||||
closeLassoMore();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeLassoMore();
|
||||
});
|
||||
|
||||
// ── Wand section ──
|
||||
document.getElementById('ge-wand-feather')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10) || 0;
|
||||
@@ -226,10 +253,10 @@ export function wireSelectionControls({
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle the translucent red overlay for the wand selection.
|
||||
// Toggle the translucent red overlay for the active selection.
|
||||
const toggleSelectionOverlay = () => {
|
||||
state.wandMaskVisible = !state.wandMaskVisible;
|
||||
['ge-wand-vis', 'ge-marquee-vis', 'ge-sam-vis'].forEach(id => {
|
||||
['ge-wand-vis', 'ge-marquee-vis', 'ge-lasso-vis', 'ge-sam-vis', 'ge-quickselect-vis', 'ge-objectselect-vis'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.innerHTML = state.wandMaskVisible ? EYE_OPEN : EYE_OFF;
|
||||
@@ -240,12 +267,66 @@ export function wireSelectionControls({
|
||||
};
|
||||
document.getElementById('ge-wand-vis')?.addEventListener('click', toggleSelectionOverlay);
|
||||
document.getElementById('ge-marquee-vis')?.addEventListener('click', toggleSelectionOverlay);
|
||||
document.getElementById('ge-lasso-vis')?.addEventListener('click', toggleSelectionOverlay);
|
||||
|
||||
document.getElementById('ge-wand-clear')?.addEventListener('click', wandClear);
|
||||
document.getElementById('ge-lasso-clear')?.addEventListener('click', wandClear);
|
||||
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
|
||||
document.getElementById('ge-wand-delete')?.addEventListener('click', wandDeleteSelection);
|
||||
document.getElementById('ge-wand-copy')?.addEventListener('click', wandCopyToNewLayer);
|
||||
document.getElementById('ge-wand-mask')?.addEventListener('click', wandToMask);
|
||||
|
||||
// SAM / Quick Select / Object Select share the wand's selection model, so
|
||||
// they get the identical action row and the same caret "More" menu pattern
|
||||
// (the shared CSS lives on the ge-wand-more-* classes).
|
||||
const wireSelectionCaret = (prefix) => {
|
||||
const btn = document.getElementById(`${prefix}-more`);
|
||||
const menu = document.getElementById(`${prefix}-more-menu`);
|
||||
if (!btn || !menu) return;
|
||||
const closeMenu = () => {
|
||||
if (menu.hidden) return;
|
||||
menu.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.classList.remove('active');
|
||||
};
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const open = menu.hidden;
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
btn.classList.toggle('active', open);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (menu.hidden) return;
|
||||
if (menu.contains(e.target) || e.target === btn) return;
|
||||
closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeMenu();
|
||||
});
|
||||
};
|
||||
['ge-sam', 'ge-quickselect', 'ge-objectselect'].forEach((prefix) => {
|
||||
document.getElementById(`${prefix}-vis`)?.addEventListener('click', toggleSelectionOverlay);
|
||||
document.getElementById(`${prefix}-clear`)?.addEventListener('click', wandClear);
|
||||
document.getElementById(`${prefix}-invert`)?.addEventListener('click', invertSelection);
|
||||
document.getElementById(`${prefix}-delete`)?.addEventListener('click', wandDeleteSelection);
|
||||
document.getElementById(`${prefix}-copy`)?.addEventListener('click', wandCopyToNewLayer);
|
||||
document.getElementById(`${prefix}-mask`)?.addEventListener('click', wandToMask);
|
||||
wireSelectionCaret(prefix);
|
||||
});
|
||||
|
||||
// Quick Select brush size was an inert slider; wire it to the tool's brush
|
||||
// size so the control actually changes the selection brush.
|
||||
const quickSize = document.getElementById('ge-quickselect-size');
|
||||
if (quickSize) {
|
||||
const quickSizeValue = document.getElementById('ge-quickselect-size-label');
|
||||
const syncQuickSize = () => {
|
||||
state.quickSelectBrushSize = Math.max(4, Math.min(200, parseInt(quickSize.value, 10) || 24));
|
||||
if (quickSizeValue) quickSizeValue.textContent = String(state.quickSelectBrushSize);
|
||||
};
|
||||
quickSize.addEventListener('input', syncQuickSize);
|
||||
syncQuickSize();
|
||||
}
|
||||
// Selection-constrained Bg Remove — reuses the same path the toolbar
|
||||
// Bg Remove button does. buildSelectionHintMask picks the active
|
||||
// wand/lasso selection, so this just kicks off the existing flow.
|
||||
|
||||
@@ -13,12 +13,23 @@ export const WORKSPACES = Object.freeze({
|
||||
photography: Object.freeze({ label: 'Photography', tab: 'histogram', width: 320, panels: Object.freeze(['layers', 'options', 'histogram', 'info', 'navigator', 'adjustments']) }),
|
||||
painting: Object.freeze({ label: 'Painting', tab: 'swatches', width: 300, panels: Object.freeze(['layers', 'options', 'swatches', 'gradients', 'patterns', 'character']) }),
|
||||
graphic: Object.freeze({ label: 'Graphic & Web', tab: 'options', width: 320, panels: Object.freeze(['layers', 'options', 'character', 'properties', 'adjustments', 'paths', 'info', 'history']) }),
|
||||
minimal: Object.freeze({ label: 'Minimal', tab: 'layers', width: 280, panels: Object.freeze(['layers', 'history']) }),
|
||||
});
|
||||
|
||||
export function applyWorkspace(name, { dock = null, rightPanel = null, setPanelVisible = null, persistKey = 'ge-right-panel-width' } = {}) {
|
||||
const workspace = WORKSPACES[name];
|
||||
let workspace = WORKSPACES[name];
|
||||
if (!workspace) {
|
||||
// A user-saved workspace ("Save Current") is stored in localStorage under
|
||||
// `ge-workspace-<name>` rather than in the frozen preset table.
|
||||
try {
|
||||
const raw = localStorage.getItem(`ge-workspace-${name}`);
|
||||
if (raw) workspace = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
/* storage unavailable — ignore */
|
||||
}
|
||||
}
|
||||
if (!workspace) return false;
|
||||
if (dock && typeof dock.show === 'function') dock.show(workspace.tab);
|
||||
if (dock && typeof dock.show === 'function' && workspace.tab) dock.show(workspace.tab);
|
||||
if (rightPanel && Number.isFinite(workspace.width)) {
|
||||
rightPanel.style.width = `${workspace.width}px`;
|
||||
try {
|
||||
|
||||
+207
-11
@@ -22,7 +22,7 @@ import {
|
||||
} from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { getSettings } from '../appConfig.js';
|
||||
import { collapseSidebarToRail } from '../modalSnap.js';
|
||||
import { clampWindowLeft, collapseSidebarToRail, leftNavRight, safeInsetPx } from '../modalSnap.js';
|
||||
import { emailApiUrl } from '../emailShared.js';
|
||||
import {
|
||||
bindMenuDismiss,
|
||||
@@ -823,7 +823,7 @@ function _readCssPx(name) {
|
||||
}
|
||||
|
||||
function _emailSplitLeftEdge() {
|
||||
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
|
||||
return leftNavRight();
|
||||
}
|
||||
|
||||
function _setEmailDocumentSplit(leftEdge, emailWidth) {
|
||||
@@ -899,7 +899,8 @@ function _clearEmailDocumentSplit() {
|
||||
// rail. Used by the "try collapsing the sidebar first" path so we can decide
|
||||
// whether collapsing recovers enough room before minimizing email.
|
||||
function _emailSplitLeftEdgeIfSidebarCollapsed() {
|
||||
return _readCssPx('--icon-rail-w');
|
||||
const navOnRight = document.getElementById('sidebar')?.classList.contains('right-side');
|
||||
return safeInsetPx('left') + (navOnRight ? 0 : _readCssPx('--icon-rail-w'));
|
||||
}
|
||||
|
||||
function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
|
||||
@@ -1122,7 +1123,175 @@ function _filenameFromContentDisposition(header) {
|
||||
return bare ? bare[1].trim() : '';
|
||||
}
|
||||
|
||||
export async function _exportSelectedAttachments() {
|
||||
async function _printSelectedEmails(messages) {
|
||||
if (messages.length > 20) throw new Error('Print / save PDF supports up to 20 emails at a time');
|
||||
if (typeof window.print !== 'function') throw new Error('Browser printing is unavailable');
|
||||
|
||||
const loaded = new Array(messages.length);
|
||||
let nextIndex = 0;
|
||||
const readWorker = async () => {
|
||||
while (nextIndex < messages.length) {
|
||||
const index = nextIndex++;
|
||||
const message = messages[index];
|
||||
try {
|
||||
const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : '';
|
||||
const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`;
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data?.error) throw new Error(data.error);
|
||||
loaded[index] = { ok: true, message, data };
|
||||
} catch (err) {
|
||||
loaded[index] = { ok: false, message, error: err?.message || 'Failed to load' };
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker));
|
||||
|
||||
const successful = loaded.filter(item => item && item.ok);
|
||||
const omitted = loaded.filter(item => !item || !item.ok);
|
||||
|
||||
if (successful.length === 0) {
|
||||
throw new Error('None of the selected emails could be loaded for printing');
|
||||
}
|
||||
|
||||
// Build sanitized HTML for the print frame -- scripts and event handlers
|
||||
// cannot execute inside the sandboxed iframe.
|
||||
const articles = [];
|
||||
if (omitted.length > 0) {
|
||||
const count = omitted.length;
|
||||
articles.push(
|
||||
`<div class="email-export-pdf-omitted-notice">Note: ${count} selected email${count === 1 ? ' was' : 's were'} unavailable and omitted from this print.</div>`
|
||||
);
|
||||
}
|
||||
|
||||
for (const { message, data } of successful) {
|
||||
const subject = String(data.subject || message.subject || '(no subject)');
|
||||
const from = data.from_name && data.from_address
|
||||
? `${data.from_name} <${data.from_address}>`
|
||||
: data.from_name || data.from_address;
|
||||
|
||||
const safeBody = new DOMParser().parseFromString(_safeRenderEmailBody(data), 'text/html');
|
||||
|
||||
// Replace interactive image placeholder components with clean print-safe notices
|
||||
safeBody.querySelectorAll('.email-inline-image-placeholder').forEach(ph => {
|
||||
const title = ph.querySelector('.email-inline-image-title')?.textContent?.trim();
|
||||
const ariaLabel = ph.getAttribute('aria-label')?.trim();
|
||||
const label = title || ariaLabel || '';
|
||||
const isGeneric = !label || /^inline image$|^remote image$|^image unavailable$|^image$/i.test(label);
|
||||
const span = safeBody.createElement('span');
|
||||
span.className = 'email-export-image-notice';
|
||||
span.textContent = isGeneric ? '[Image omitted]' : `[Image: ${label}]`;
|
||||
ph.replaceWith(span);
|
||||
});
|
||||
|
||||
// Replace any remaining raw <img> tags
|
||||
safeBody.querySelectorAll('img').forEach(img => {
|
||||
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
|
||||
const span = safeBody.createElement('span');
|
||||
span.className = 'email-export-image-notice';
|
||||
span.textContent = alt ? `[Image: ${alt}]` : '[Image omitted]';
|
||||
img.replaceWith(span);
|
||||
});
|
||||
|
||||
// Strip interactive controls (buttons, inputs, actions) from the print document
|
||||
safeBody.querySelectorAll('button, input, select, textarea, .email-inline-image-actions, .email-inline-image-btn').forEach(el => el.remove());
|
||||
|
||||
safeBody.querySelectorAll('details').forEach(detail => { detail.open = true; });
|
||||
const bodyHtml = safeBody.body.innerHTML;
|
||||
|
||||
const fieldParts = [];
|
||||
for (const [lbl, val] of [['From', from], ['To', data.to], ['Cc', data.cc], ['Date', data.date_display || data.date || message.date]]) {
|
||||
if (!val) continue;
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
articles.push(
|
||||
`<article class="email-export-pdf-message">` +
|
||||
`<h1>${esc(subject)}</h1>` +
|
||||
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
|
||||
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
const printCss = [
|
||||
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
|
||||
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
|
||||
'.email-export-pdf-message{page-break-after:always;break-after:page}',
|
||||
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
|
||||
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
|
||||
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
|
||||
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
|
||||
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
|
||||
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
|
||||
'.email-export-pdf-body img{max-width:100%}',
|
||||
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
|
||||
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
|
||||
'.email-export-pdf-body pre{white-space:pre-wrap}',
|
||||
'.email-inline-image-placeholder,.email-inline-image-actions,.email-inline-image-btn{display:none !important}',
|
||||
'.email-export-image-notice{display:inline-block;color:#666;font-style:italic;font-size:11px;margin:4px 0}',
|
||||
'.email-export-pdf-omitted-notice{margin:0 0 16px;padding:8px 12px;background:#f8f9fa;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;color:#64748b}',
|
||||
'@page{margin:10mm}',
|
||||
].join('\n');
|
||||
|
||||
const safeBaseName = successful.length === 1
|
||||
? String(successful[0]?.data?.subject || successful[0]?.message?.subject || 'email').replace(/[^\p{L}\p{N} ._-]+/gu, '_').trim().slice(0, 100) || 'email'
|
||||
: 'selected-emails';
|
||||
|
||||
// Use a sandboxed same-origin iframe following the established document.js
|
||||
// Print / save PDF pattern (SAN-157). The browser opens a print dialog;
|
||||
// saving, cancellation and pagination belong to the browser.
|
||||
document.getElementById('email-browser-print-frame')?.remove();
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'email-browser-print-frame';
|
||||
frame.title = 'Email print view';
|
||||
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
|
||||
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
|
||||
document.body.appendChild(frame);
|
||||
|
||||
let cleanedUp = false;
|
||||
const cleanupFrame = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
try {
|
||||
frame.remove();
|
||||
} catch (_) {}
|
||||
window.removeEventListener('afterprint', cleanupFrame);
|
||||
};
|
||||
|
||||
try {
|
||||
const printDoc = frame.contentDocument;
|
||||
printDoc.open();
|
||||
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
|
||||
printDoc.close();
|
||||
printDoc.title = safeBaseName;
|
||||
const printStyle = printDoc.createElement('style');
|
||||
printStyle.textContent = printCss;
|
||||
printDoc.head.appendChild(printStyle);
|
||||
const container = printDoc.createElement('div');
|
||||
container.className = 'email-export-pdf';
|
||||
container.innerHTML = articles.join('');
|
||||
printDoc.body.appendChild(container);
|
||||
await printDoc.fonts.ready;
|
||||
frame.contentWindow?.addEventListener('afterprint', cleanupFrame, { once: true });
|
||||
window.addEventListener('afterprint', cleanupFrame, { once: true });
|
||||
setTimeout(cleanupFrame, 60000);
|
||||
frame.contentWindow.focus();
|
||||
frame.contentWindow.print();
|
||||
if (omitted.length > 0) {
|
||||
const msg = `Prepared ${successful.length} email${successful.length === 1 ? '' : 's'} for printing; ${omitted.length} unavailable`;
|
||||
showToast(msg);
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupFrame();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _exportSelectedBundle(kind) {
|
||||
const messages = _selectedEmailExportPayload();
|
||||
if (!messages.length) {
|
||||
showToast('Select emails first');
|
||||
@@ -1150,8 +1319,14 @@ export async function _exportSelectedAttachments() {
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
|
||||
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
|
||||
const isEmailExport = kind === 'emails';
|
||||
const isPrintExport = kind === 'print';
|
||||
if (countEl) countEl.textContent = `${isPrintExport ? 'Preparing print' : 'Exporting'} ${isPrintExport ? 'for' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
|
||||
if (isPrintExport) {
|
||||
await _printSelectedEmails(messages);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1169,12 +1344,21 @@ export async function _exportSelectedAttachments() {
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
|
||||
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition'))
|
||||
|| (isEmailExport
|
||||
? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip')
|
||||
: 'selected-email-attachments.zip');
|
||||
_downloadBlob(blob, filename);
|
||||
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
|
||||
const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length;
|
||||
const skipped = Number(res.headers.get('X-Skipped-Emails')) || 0;
|
||||
showToast(skipped
|
||||
? `Exported ${exported} email${exported === 1 ? '' : 's'}; ${skipped} unavailable`
|
||||
: isEmailExport
|
||||
? `Exported ${exported} email${exported === 1 ? '' : 's'} as ${exported === 1 ? 'an .eml file' : '.eml files'}`
|
||||
: `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to export selected email attachments:', err);
|
||||
showToast(err?.message || 'Failed to export attachments');
|
||||
console.error(`Failed to export selected email ${kind}:`, err);
|
||||
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments'));
|
||||
} finally {
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (actionsBtn) {
|
||||
@@ -1190,6 +1374,18 @@ export async function _exportSelectedAttachments() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function _exportSelectedEmails() {
|
||||
return _exportSelectedBundle('emails');
|
||||
}
|
||||
|
||||
export async function _exportSelectedEmailPdf() {
|
||||
return _exportSelectedBundle('print');
|
||||
}
|
||||
|
||||
export async function _exportSelectedAttachments() {
|
||||
return _exportSelectedBundle('attachments');
|
||||
}
|
||||
|
||||
// URL-suffix helper — appends &account_id=... when an account is actively selected.
|
||||
// Every email route call in this file goes through here so switching accounts
|
||||
// is a single-variable flip.
|
||||
@@ -2415,7 +2611,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
requestAnimationFrame(() => {
|
||||
const w = content.offsetWidth;
|
||||
const refH = window.innerHeight * 0.85;
|
||||
content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px';
|
||||
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2), w) + 'px';
|
||||
content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
|
||||
content.style.transform = 'none';
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
_animateEmailCardRemoval,
|
||||
_clearDoneResponseTagsLocal,
|
||||
_emailMutationQuery,
|
||||
_exportSelectedEmailPdf,
|
||||
_exportSelectedEmails,
|
||||
_exportSelectedAttachments,
|
||||
_findSiblingEmailCard,
|
||||
_fitEmailDropdown,
|
||||
@@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) {
|
||||
const _unreadIco = '<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="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _exportIco = '<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>';
|
||||
const _emailIco = '<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="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
|
||||
const _pdfIco = '<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 15h8M8 18h5"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Export Emails', icon: _exportIco, submenu: true },
|
||||
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
@@ -594,8 +599,45 @@ export function _showBulkActionsMenu(anchor) {
|
||||
for (const a of items) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>${a.submenu ? '<span style="margin-left:auto;opacity:0.55;">›</span>' : ''}`;
|
||||
it.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (a.submenu) {
|
||||
dropdown.replaceChildren();
|
||||
const back = document.createElement('div');
|
||||
back.className = 'dropdown-item-compact email-bulk-submenu-back';
|
||||
back.innerHTML = '<span style="opacity:.65;margin-right:4px;">‹</span><span>Export Emails</span>';
|
||||
back.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
close();
|
||||
_showBulkActionsMenu(anchor);
|
||||
});
|
||||
dropdown.appendChild(back);
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
dropdown.appendChild(divider);
|
||||
const options = [
|
||||
{ label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails },
|
||||
{ label: 'Print / save PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
|
||||
];
|
||||
for (const option of options) {
|
||||
const child = document.createElement('div');
|
||||
child.className = 'dropdown-item-compact';
|
||||
child.innerHTML = `<span class="dropdown-icon">${option.icon}</span><span>${option.label}</span>`;
|
||||
child.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
close();
|
||||
option.action();
|
||||
});
|
||||
dropdown.appendChild(child);
|
||||
}
|
||||
_fitEmailDropdown(dropdown, anchor.getBoundingClientRect());
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import * as Modals from '../modalManager.js';
|
||||
import { clampWindowLeft } from '../modalSnap.js';
|
||||
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { state } from './state.js';
|
||||
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
|
||||
@@ -428,7 +429,7 @@ export async function _openEmailWindow(em, folder) {
|
||||
requestAnimationFrame(() => {
|
||||
const w = content.offsetWidth, h = content.offsetHeight;
|
||||
const off = (_emailWindowSeq % 6) * 28;
|
||||
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
|
||||
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2 + off), w) + 'px';
|
||||
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function createPanel({
|
||||
// which left it as a side panel squeezing the chat).
|
||||
if (window.innerWidth <= 768) {
|
||||
pane.style.position = 'fixed';
|
||||
pane.style.inset = '0';
|
||||
pane.style.inset = 'var(--sheet-top) 0 0';
|
||||
pane.style.width = '100%';
|
||||
pane.style.maxWidth = '100%';
|
||||
pane.style.zIndex = '170';
|
||||
|
||||
+893
-185
File diff suppressed because it is too large
Load Diff
@@ -369,6 +369,31 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
/* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
|
||||
{
|
||||
if (window.visualViewport) {
|
||||
const root = document.documentElement;
|
||||
// iOS overlays the keyboard without resizing the layout viewport and scrolls
|
||||
// the page to reveal the field, leaving it shifted after the keyboard closes.
|
||||
const _syncKeyboardViewport = function() {
|
||||
const vv = window.visualViewport;
|
||||
if (vv.scale > 1.01) return;
|
||||
const keyboardOpen = window.innerHeight - vv.height > 120;
|
||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||
else root.style.removeProperty('--keyboard-viewport-height');
|
||||
if (window.scrollY) window.scrollTo(0, 0);
|
||||
};
|
||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||
const _syncThroughKeyboardAnimation = () => {
|
||||
_syncKeyboardViewport();
|
||||
for (const delay of [100, 300, 600, 1000]) setTimeout(_syncKeyboardViewport, delay);
|
||||
};
|
||||
_syncKeyboardViewport();
|
||||
window.visualViewport.addEventListener('resize', _syncThroughKeyboardAnimation);
|
||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
||||
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
const vv = window.visualViewport;
|
||||
|
||||
+45
-7
@@ -110,7 +110,7 @@ function _clampRightDockWidth(width) {
|
||||
const min = _minEdgeDockWidth();
|
||||
const navRight = _leftNavRight();
|
||||
const leftDockW = _activeDockWidth('left');
|
||||
const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH;
|
||||
const maxByChat = window.innerWidth - navRight - _rightNavInset() - leftDockW - MIN_CHAT_WIDTH;
|
||||
const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
|
||||
return _clampDockWidthToSpace(width, min, max);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function _clampRightDockWidth(width) {
|
||||
function _clampLeftDockWidth(width, left = _leftNavRight()) {
|
||||
const min = _minEdgeDockWidth();
|
||||
const rightDockW = _activeDockWidth('right');
|
||||
const available = Math.max(0, window.innerWidth - left - rightDockW);
|
||||
const available = Math.max(0, window.innerWidth - left - _rightNavInset() - rightDockW);
|
||||
const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
|
||||
return _clampDockWidthToSpace(width, min, max);
|
||||
}
|
||||
@@ -170,13 +170,23 @@ function _shouldAutoCollapseSidebar(dockW) {
|
||||
return remaining < MIN_CHAT_WIDTH;
|
||||
}
|
||||
|
||||
function _navOnRight() {
|
||||
return !!document.getElementById('sidebar')?.classList.contains('right-side');
|
||||
}
|
||||
|
||||
// Body padding carries the device safe-area insets (notch, Dynamic Island).
|
||||
function _bodyPaddingPx(side) {
|
||||
return parseFloat(window.getComputedStyle(document.body)[side === 'left' ? 'paddingLeft' : 'paddingRight']) || 0;
|
||||
}
|
||||
|
||||
// Right edge (px) of whatever left navigation is currently showing — the
|
||||
// expanded sidebar if visible, otherwise the icon rail. Used to anchor the
|
||||
// left dock so it always sits flush to the right of the nav.
|
||||
function _leftNavRight() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
let x = 0;
|
||||
let x = _bodyPaddingPx('left');
|
||||
if (_navOnRight()) return x;
|
||||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||
const r = sidebar.getBoundingClientRect();
|
||||
if (r.width) x = Math.max(x, r.right);
|
||||
@@ -188,8 +198,36 @@ function _leftNavRight() {
|
||||
return x;
|
||||
}
|
||||
|
||||
// Distance (px) from the viewport's right edge that a right-side nav and the
|
||||
// safe area occupy. The right dock sits flush to the left of it.
|
||||
function _rightNavInset() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
let inset = _bodyPaddingPx('right');
|
||||
if (!_navOnRight()) return inset;
|
||||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||
const r = sidebar.getBoundingClientRect();
|
||||
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
|
||||
}
|
||||
if (rail && window.getComputedStyle(rail).display !== 'none') {
|
||||
const r = rail.getBoundingClientRect();
|
||||
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
|
||||
}
|
||||
return inset;
|
||||
}
|
||||
|
||||
export function leftNavRight() { return _leftNavRight(); }
|
||||
export function rightNavInset() { return _rightNavInset(); }
|
||||
export function safeInsetPx(side) { return _bodyPaddingPx(side); }
|
||||
|
||||
// Keeps a floating window between the nav and the safe areas
|
||||
export function clampWindowLeft(left, width) {
|
||||
const max = window.innerWidth - _rightNavInset() - width;
|
||||
return Math.max(_leftNavRight(), Math.min(left, max));
|
||||
}
|
||||
|
||||
function _clampEmailDocSplitWidth(width, left = _leftNavRight()) {
|
||||
const available = Math.max(0, window.innerWidth - left);
|
||||
const available = Math.max(0, window.innerWidth - left - _rightNavInset());
|
||||
if (!available) return 0;
|
||||
const compact = available < 760;
|
||||
const minEmail = compact ? 260 : 340;
|
||||
@@ -235,7 +273,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
|
||||
if (!docPane || window.innerWidth <= 768) return;
|
||||
docPane.style.setProperty('position', 'fixed', 'important');
|
||||
docPane.style.setProperty('left', `${x}px`, 'important');
|
||||
docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important');
|
||||
docPane.style.setProperty('right', `calc(var(--right-dock-w, 0px) + ${_rightNavInset()}px)`, 'important');
|
||||
docPane.style.setProperty('top', '0px', 'important');
|
||||
docPane.style.setProperty('bottom', '0px', 'important');
|
||||
docPane.style.setProperty('width', 'auto', 'important');
|
||||
@@ -497,7 +535,7 @@ function _applyDockInternal(modal, side, dockClass) {
|
||||
} else {
|
||||
w = _resolveRightDockWidth(modal, content);
|
||||
content.style.left = 'auto';
|
||||
content.style.right = '0';
|
||||
content.style.right = _rightNavInset() + 'px';
|
||||
content.style.width = w + 'px';
|
||||
content.style.maxWidth = w + 'px';
|
||||
document.body.classList.add('right-dock-active');
|
||||
@@ -872,7 +910,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
||||
w = _clampRightDockWidth(window.innerWidth - clientX);
|
||||
content._userDockWidth = w;
|
||||
content.style.left = 'auto';
|
||||
content.style.right = '0';
|
||||
content.style.right = _rightNavInset() + 'px';
|
||||
content.style.width = w + 'px';
|
||||
content.style.maxWidth = w + 'px';
|
||||
document.body.classList.add('right-dock-active');
|
||||
|
||||
@@ -75,12 +75,15 @@ export function initSidebarLayout(Storage, opts) {
|
||||
_setSidebarModeClasses(mode);
|
||||
}
|
||||
|
||||
// Phones in landscape get the desktop layout, but the icon rail is too small to use there
|
||||
const _phoneLandscape = window.matchMedia('(pointer: coarse) and (max-height: 500px)');
|
||||
|
||||
function _syncRailSideCore() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!iconRail) return;
|
||||
const isRight = sidebar.classList.contains('right-side');
|
||||
const sidebarHidden = sidebar.classList.contains('hidden');
|
||||
const railHidden = iconRail.classList.contains('rail-hidden');
|
||||
const railHidden = iconRail.classList.contains('rail-hidden') || _phoneLandscape.matches;
|
||||
const isMobileMini = iconRail.classList.contains('mobile-mini');
|
||||
iconRail.classList.toggle('right-side', isRight);
|
||||
// On mobile mini mode, JS already set inline styles — don't touch
|
||||
@@ -118,6 +121,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// Set initial reference and expose globally
|
||||
_syncRailSideFn = _syncRailSideCore;
|
||||
window.syncRailSide = syncRailSide;
|
||||
_phoneLandscape.addEventListener('change', () => syncRailSide());
|
||||
|
||||
// Restore sidebar side preference
|
||||
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
|
||||
|
||||
+51
-2
@@ -31,9 +31,43 @@ function _targetEl(target) {
|
||||
return target && target.nodeType === 1 ? target : target?.parentElement || null;
|
||||
}
|
||||
|
||||
function _editorPanelVisible() {
|
||||
if (!document.body || !document.body.classList.contains('ge-editor-open')) return false;
|
||||
const sel = '.ge-adj-popup, .ge-fx-popup, .ge-fx-menu, .ge-transform-popup, .ge-filter-overlay, .ge-history-panel';
|
||||
return [...document.querySelectorAll(sel)].some((candidate) => {
|
||||
if (!document.contains(candidate)) return false;
|
||||
const style = getComputedStyle(candidate);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function _positionToastForOpenPanel(el) {
|
||||
if (!el) return;
|
||||
// The Free Transform hint is pinned by CSS to one fixed spot under the
|
||||
// canvas; letting the panel rules move it made it hop between the top-right
|
||||
// corner and the bottom banner depending on which panel happened to be open.
|
||||
if (el.classList.contains('toast-transform-hint')) {
|
||||
el.classList.remove('toast-editor-bottom', 'toast-panel-offset');
|
||||
el.style.left = '';
|
||||
el.style.right = '';
|
||||
el.style.maxWidth = '';
|
||||
return;
|
||||
}
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
el.classList.remove('toast-editor-bottom');
|
||||
// In the editor, a floating panel (Adjust/Develop, FX, Transform, filter
|
||||
// modal, History) is anchored to the top/right; drop the toast to the
|
||||
// bottom-centre so it can never cover a control (EVAL: "never over an
|
||||
// active panel"). On phones the CSS already anchors it at the bottom.
|
||||
if (_editorPanelVisible()) {
|
||||
el.style.left = '';
|
||||
el.style.right = '';
|
||||
el.style.maxWidth = '';
|
||||
el.classList.add('toast-editor-bottom');
|
||||
return;
|
||||
}
|
||||
const panel = [...document.querySelectorAll('.notes-pane')]
|
||||
.filter((candidate) => {
|
||||
if (!document.contains(candidate)) return false;
|
||||
@@ -174,7 +208,9 @@ function _closeHoveredWindow() {
|
||||
function _spaceIsBlocked(e, surface) {
|
||||
const target = _targetEl(e.target);
|
||||
if (!target) return false;
|
||||
if (_isTextEditingTarget(target)) return !surface || surface.contains(target);
|
||||
// Space must always type in a field, even when that field lives in a fixed
|
||||
// overlay outside the hovered surface (e.g. the editor's on-canvas text box).
|
||||
if (_isTextEditingTarget(target)) return true;
|
||||
const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR);
|
||||
return !!(blocked && (!surface || surface.contains(blocked)));
|
||||
}
|
||||
@@ -221,6 +257,10 @@ function _initHoverCardSpaceToggle() {
|
||||
}, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code !== 'Space' || e.repeat) return;
|
||||
// The image editor owns Space (hold to pan, otherwise text fields type a
|
||||
// space). A card/window toggle must never fire while it is open, or the
|
||||
// hovered gallery card can click itself closed underneath the editor.
|
||||
if (document.body?.classList.contains('ge-editor-open')) return;
|
||||
if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {
|
||||
if (_spaceIsBlocked(e, hoveredToggleCard)) return;
|
||||
e.preventDefault();
|
||||
@@ -342,7 +382,7 @@ export function showToast(msg, durationOrOpts) {
|
||||
_wireToastSwipe(toastEl);
|
||||
toastEl.textContent = '';
|
||||
toastEl.classList.remove('error');
|
||||
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent');
|
||||
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent', 'toast-transform-hint');
|
||||
|
||||
let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null, toastClass = null, aiReplyProgress = false, aiReplyResult = false;
|
||||
if (typeof durationOrOpts === 'object' && durationOrOpts) {
|
||||
@@ -358,6 +398,14 @@ export function showToast(msg, durationOrOpts) {
|
||||
} else if (typeof durationOrOpts === 'number') {
|
||||
duration = durationOrOpts;
|
||||
}
|
||||
// While the editor is open, cap every toast so it auto-dismisses within
|
||||
// ~2.5s and can never linger over the canvas or a panel (EVAL: "auto-dismiss
|
||||
// in 2.5s"). Longer informational durations (persist warnings etc.) are
|
||||
// still readable but do not pile up.
|
||||
if (typeof document !== 'undefined' && document.body
|
||||
&& document.body.classList.contains('ge-editor-open')) {
|
||||
duration = Math.min(duration, 2500);
|
||||
}
|
||||
if (aiReplyProgress) toastEl.classList.add('toast-ai-reply-progress');
|
||||
if (aiReplyResult) toastEl.classList.add('toast-ai-reply-result');
|
||||
if (toastClass) toastEl.classList.add(toastClass);
|
||||
@@ -473,6 +521,7 @@ export function showError(msg) {
|
||||
_wireToastSwipe(toastEl);
|
||||
toastEl.textContent = '';
|
||||
toastEl.classList.add('error');
|
||||
toastEl.classList.remove('toast-transform-hint');
|
||||
toastEl.style.pointerEvents = '';
|
||||
toastEl.style.transform = '';
|
||||
toastEl.classList.remove('exiting');
|
||||
|
||||
@@ -257,12 +257,19 @@ export function makeWindowResizable(content, options = {}) {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
|
||||
if (saved && saved.w && saved.h) {
|
||||
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
|
||||
const bodyStyle = getComputedStyle(document.body);
|
||||
const padLeft = parseFloat(bodyStyle.paddingLeft) || 0;
|
||||
const padRight = parseFloat(bodyStyle.paddingRight) || 0;
|
||||
const w = Math.max(minW, Math.min(saved.w, window.innerWidth - padLeft - padRight));
|
||||
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
|
||||
content.style.width = w + 'px';
|
||||
content.style.height = h + 'px';
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user