Merge current dev and refresh PR #6741 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:28 +00:00
295 changed files with 24233 additions and 1587 deletions
+4 -1
View File
@@ -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();
+301 -171
View File
@@ -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">&#8596;</button>
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button>
</div>
<div class="ge-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>
+59 -4
View File
@@ -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
+38 -16
View File
@@ -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];
+1 -1
View File
@@ -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">
+2 -1
View File
@@ -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
+50 -16
View File
@@ -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);
}
}
+23 -8
View File
@@ -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 &amp; 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 &amp; 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>
+2 -2
View File
@@ -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>
+6
View File
@@ -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);
+11 -5
View File
@@ -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;
+25 -15
View File
@@ -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 ──
+11 -4
View File
@@ -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 };
}
+18 -6
View File
@@ -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;
+38 -3
View File
@@ -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;
}
+11 -1
View File
@@ -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':
+93 -7
View File
@@ -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.
+42 -6
View File
@@ -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),
},
};
}
+87 -4
View File
@@ -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 -------------------------------------------------------------
+8 -8
View File
@@ -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);
+102 -9
View File
@@ -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);
}
+3
View File
@@ -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);
+3 -1
View File
@@ -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 = () => {
+9
View File
@@ -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,
+19 -4
View File
@@ -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
+91 -4
View File
@@ -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();
});
}
+66 -3
View File
@@ -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();
+4 -3
View File
@@ -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. */
+1 -1
View File
@@ -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',
});
+20 -2
View File
@@ -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);
+16 -9
View File
@@ -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) {
+7 -3
View File
@@ -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);
},
};
}
+177
View File
@@ -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; }
},
};
}
+2 -1
View File
@@ -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);
};
+59 -11
View File
@@ -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();
},
};
+12 -5
View File
@@ -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;
};
+4 -3
View File
@@ -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;
}
+55 -1
View File
@@ -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({
+74 -17
View File
@@ -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;
+34 -11
View File
@@ -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;
+130 -9
View File
@@ -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;
}
+33 -3
View File
@@ -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');
+83 -2
View File
@@ -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 -2
View File
@@ -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 {