mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 14:22:19 +02:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,80 @@
|
||||
/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */
|
||||
|
||||
export function createCommandPalette({ commands = [], onRun = null } = {}) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ge-command-palette';
|
||||
overlay.hidden = true;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Command palette');
|
||||
overlay.innerHTML = `
|
||||
<div class="ge-command-panel">
|
||||
<input class="ge-command-input" type="text" placeholder="Type a command..." aria-label="Command" autocomplete="off" />
|
||||
<div class="ge-command-list" role="listbox"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = overlay.querySelector('.ge-command-input');
|
||||
const list = overlay.querySelector('.ge-command-list');
|
||||
let filtered = [];
|
||||
let activeIndex = 0;
|
||||
|
||||
function render() {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query));
|
||||
list.innerHTML = '';
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'ge-command-empty';
|
||||
empty.textContent = 'No matching commands';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (activeIndex >= filtered.length) activeIndex = 0;
|
||||
filtered.forEach((cmd, index) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false');
|
||||
row.textContent = cmd.label;
|
||||
row.addEventListener('click', () => run(cmd));
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
close();
|
||||
if (typeof cmd.run === 'function') cmd.run();
|
||||
else if (typeof onRun === 'function') onRun(cmd);
|
||||
}
|
||||
|
||||
function open() {
|
||||
overlay.hidden = false;
|
||||
input.value = '';
|
||||
activeIndex = 0;
|
||||
render();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
overlay.hidden = true;
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => { activeIndex = 0; render(); });
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); }
|
||||
else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); }
|
||||
});
|
||||
overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
|
||||
|
||||
return {
|
||||
element: overlay,
|
||||
open,
|
||||
close,
|
||||
toggle: () => (overlay.hidden ? open() : close()),
|
||||
isOpen: () => !overlay.hidden,
|
||||
};
|
||||
}
|
||||
Vendored
+150
-29
@@ -6,10 +6,10 @@
|
||||
* sections are all toggled `display:none` here; the tool-switch handler
|
||||
* in galleryEditor.js shows the section matching the active tool.
|
||||
*
|
||||
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx
|
||||
* @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div id="ge-pen-section" style="display:none;">
|
||||
@@ -96,19 +96,39 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
|
||||
<div class="ge-control-row"><label for="ge-eyedropper-sample-size">Sample Size</label><select id="ge-eyedropper-sample-size" title="Point samples one pixel; the averages blur the reading over an N-by-N block.">${[1, 3, 5, 11, 31, 51].map((n) => `<option value="${n}"${Number(eyedropperSampleSize) === n ? ' selected' : ''}>${n === 1 ? 'Point Sample' : `${n} by ${n} Average`}</option>`).join('')}</select></div>
|
||||
</div>
|
||||
<div class="ge-bucket-section" id="ge-bucket-section" style="display:none;">
|
||||
<div class="ge-section-title">Paint Bucket</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label for="ge-bucket-tolerance">Tolerance <span id="ge-bucket-tolerance-label">${bucketTolerance}</span></label>
|
||||
<input type="range" id="ge-bucket-tolerance" min="0" max="255" value="${bucketTolerance}" title="How different a neighbouring pixel can be and still be filled (0-255)." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-crop-section" id="ge-crop-section" style="display:none;">
|
||||
<div class="ge-section-title">Crop</div>
|
||||
<div class="ge-control-row ge-crop-preset-row" id="ge-crop-presets" role="group" aria-label="Crop aspect ratio">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'free' ? ' active' : ''}" data-crop-preset="free" title="Crop freely (no aspect lock)">Free</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'original' ? ' active' : ''}" data-crop-preset="original" title="Keep the original image aspect ratio">Original</button>
|
||||
<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>
|
||||
</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>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
||||
<select id="ge-text-font">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Inter">Inter</option>
|
||||
<option value="Roboto">Roboto</option>
|
||||
<option value="Playfair Display">Playfair Display</option>
|
||||
<option value="Lobster">Lobster</option>
|
||||
<option value="Bebas Neue">Bebas Neue</option>
|
||||
<option value="Roboto Mono">Roboto Mono</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="system-ui">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
||||
@@ -150,6 +170,36 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
||||
</div>
|
||||
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Writing</span>
|
||||
<select id="ge-text-direction"><option value="horizontal">Horizontal</option><option value="vertical">Vertical</option></select>
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Warp</span>
|
||||
<select id="ge-text-warp">
|
||||
<option value="none">None</option>
|
||||
<option value="arc">Arc</option>
|
||||
<option value="arch">Arch</option>
|
||||
<option value="bulge">Bulge</option>
|
||||
<option value="flag">Flag</option>
|
||||
<option value="wave">Wave</option>
|
||||
<option value="rise">Rise</option>
|
||||
<option value="fisheye">Fisheye</option>
|
||||
<option value="inflate">Inflate</option>
|
||||
<option value="squeeze">Squeeze</option>
|
||||
<option value="twist">Twist</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Warp amt</span>
|
||||
<input id="ge-text-warp-amount" type="range" min="-100" max="100" step="1" value="0" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid ge-text-opentype-row">
|
||||
<label class="ge-text-field ge-text-inline-check"><span>Small caps</span><input type="checkbox" id="ge-text-small-caps" /></label>
|
||||
<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>
|
||||
<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;">
|
||||
@@ -186,6 +236,22 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-path-section" id="ge-path-section" style="display:none;">
|
||||
<div class="ge-section-title">Path</div>
|
||||
<p class="ge-section-hint">Click to add anchors; double-click or press Enter to finish.</p>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-path-fill" value="${color}" /></label>
|
||||
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-path-stroke" value="#111111" /></label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Width</span><input id="ge-path-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
|
||||
<label class="ge-text-field ge-path-closed-field"><span>Close path</span><input id="ge-path-closed" type="checkbox" checked /></label>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;">
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-finish">Finish</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
|
||||
@@ -244,6 +310,17 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<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>
|
||||
@@ -347,6 +424,31 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
|
||||
@@ -619,27 +721,6 @@ export function layerPanelHTML() {
|
||||
return `<div class="ge-layers-header">
|
||||
<span class="ge-layers-grab"></span>
|
||||
<span class="ge-layers-title">Layers</span>
|
||||
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
|
||||
<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 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-blend-row">
|
||||
<label for="ge-layer-blend">Blend</label>
|
||||
@@ -660,7 +741,21 @@ export function layerPanelHTML() {
|
||||
<option value="saturation">Saturation</option>
|
||||
<option value="color">Color</option>
|
||||
<option value="luminosity">Luminosity</option>
|
||||
<option value="dissolve">Dissolve</option>
|
||||
<option value="linear-burn">Linear Burn</option>
|
||||
<option value="linear-dodge">Linear Dodge (Add)</option>
|
||||
<option value="darker-color">Darker Color</option>
|
||||
<option value="lighter-color">Lighter Color</option>
|
||||
<option value="vivid-light">Vivid Light</option>
|
||||
<option value="linear-light">Linear Light</option>
|
||||
<option value="pin-light">Pin Light</option>
|
||||
<option value="hard-mix">Hard Mix</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
<option value="divide">Divide</option>
|
||||
</select>
|
||||
<label class="ge-layer-opacity-head" for="ge-layer-header-opacity" title="Active layer opacity">Opacity
|
||||
<input type="number" id="ge-layer-header-opacity" min="0" max="100" step="1" value="100" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
||||
<span id="ge-layer-selection-count">2 layers</span>
|
||||
@@ -676,5 +771,31 @@ export function layerPanelHTML() {
|
||||
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
||||
</div>
|
||||
<div class="ge-layers-list" id="ge-layers-list"></div>
|
||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
|
||||
<div class="ge-layers-footer">
|
||||
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn ge-add-fx" id="ge-add-fx" title="New adjustment (fx) layer" aria-label="New adjustment layer">
|
||||
<span class="ge-add-fx-glyph" aria-hidden="true">fx</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
|
||||
<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 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
||||
</div>
|
||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Docked panel stack (PhotoCraft-parity phase 0/3).
|
||||
*
|
||||
* Wraps a list of existing panel elements into a Photoshop-style dock:
|
||||
* several panel GROUPS are visible at once, stacked vertically. Each
|
||||
* group has clickable tabs (one panel visible per group), a collapse
|
||||
* control that shrinks it to its tab strip, and a drag divider so the
|
||||
* user can resize the group above.
|
||||
*
|
||||
* Purely presentational: it never owns editor state. Each entry is an
|
||||
* element that already exists elsewhere (so all existing ids, classes
|
||||
* and event listeners keep working). Switching a tab only toggles the
|
||||
* `hidden` attribute on the panel wrapper.
|
||||
*
|
||||
* A compact `<select>` variant (`compact: true`) is kept as a fallback
|
||||
* for narrow columns where a multi-group stack will not fit.
|
||||
*
|
||||
* @param {{
|
||||
* entries: Array<{id: string, label: string, element: HTMLElement, group?: string}>,
|
||||
* persistKey?: string,
|
||||
* defaultId?: string,
|
||||
* compact?: boolean,
|
||||
* }} opts
|
||||
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
|
||||
*/
|
||||
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false } = {}) {
|
||||
const dock = document.createElement('div');
|
||||
dock.className = 'ge-dock';
|
||||
if (compact) dock.classList.add('ge-dock--compact');
|
||||
|
||||
// Collect entries into ordered groups (Photoshop panel groups).
|
||||
const groups = [];
|
||||
entries.forEach(entry => {
|
||||
const name = entry.group || 'Panels';
|
||||
let group = groups.find(g => g.name === name);
|
||||
if (!group) {
|
||||
group = { name, entries: [], size: entry.groupSize || null, collapsed: !!entry.groupCollapsed };
|
||||
groups.push(group);
|
||||
}
|
||||
group.entries.push(entry);
|
||||
});
|
||||
|
||||
const groupOfId = new Map();
|
||||
groups.forEach(group => group.entries.forEach(e => groupOfId.set(e.id, group)));
|
||||
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem(persistKey) || '{}') || {}; } catch {}
|
||||
const activeByGroup = new Map();
|
||||
groups.forEach(group => {
|
||||
const fallback = group.entries[0]?.id || null;
|
||||
const savedId = saved[group.name];
|
||||
const pick = (savedId && group.entries.some(e => e.id === savedId)) ? savedId
|
||||
: (defaultId && group.entries.some(e => e.id === defaultId)) ? defaultId
|
||||
: fallback;
|
||||
activeByGroup.set(group.name, pick);
|
||||
});
|
||||
|
||||
let activeId = null;
|
||||
const persist = () => {
|
||||
const out = {};
|
||||
activeByGroup.forEach((v, k) => { if (v) out[k] = v; });
|
||||
try { localStorage.setItem(persistKey, JSON.stringify(out)); } catch {}
|
||||
};
|
||||
|
||||
const groupEls = new Map();
|
||||
const detachPlacers = new Map();
|
||||
const activateInGroup = (group, id) => {
|
||||
activeByGroup.set(group.name, id);
|
||||
activeId = id;
|
||||
groupEls.get(group.name)?.querySelectorAll('.ge-dock-tab').forEach(tab => {
|
||||
const on = tab.dataset.panel === id;
|
||||
tab.classList.toggle('active', on);
|
||||
tab.setAttribute('aria-selected', String(on));
|
||||
});
|
||||
group.entries.forEach(entry => {
|
||||
const panel = groupEls.get(group.name)?.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
|
||||
if (panel) panel.hidden = entry.id !== id;
|
||||
});
|
||||
// Keep the group's undock control anchored beside the active tab.
|
||||
detachPlacers.get(group.name)?.();
|
||||
persist();
|
||||
};
|
||||
|
||||
function show(id) {
|
||||
const group = groupOfId.get(id);
|
||||
if (!group) return;
|
||||
groupEls.get(group.name)?.classList.remove('is-collapsed');
|
||||
activateInGroup(group, id);
|
||||
}
|
||||
|
||||
// A dock tab plus a small "undock" control (Photoshop lets you tear a single
|
||||
// panel tab out of its group). Used both for the initial build and when a
|
||||
// floated panel is re-docked.
|
||||
function makeTabItem(group, entry, groupEl) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ge-dock-tab-item';
|
||||
item.dataset.panel = entry.id;
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'ge-dock-tab';
|
||||
tab.dataset.panel = entry.id;
|
||||
tab.textContent = entry.label;
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.setAttribute('aria-selected', 'false');
|
||||
tab.title = entry.label;
|
||||
tab.addEventListener('click', () => {
|
||||
groupEl.classList.remove('is-collapsed');
|
||||
activateInGroup(group, entry.id);
|
||||
});
|
||||
item.appendChild(tab);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Float a single panel into its own draggable window (Photoshop undock-tab).
|
||||
function floatPanel(group, entry) {
|
||||
const groupEl = groupEls.get(group.name);
|
||||
if (!groupEl) return;
|
||||
const panelEl = groupEl.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
|
||||
if (!panelEl || panelEl.closest('.ge-float-window')) return;
|
||||
const win = document.createElement('div');
|
||||
win.className = 'ge-float-window';
|
||||
win.innerHTML = `
|
||||
<div class="ge-float-head">
|
||||
<span class="ge-float-title">${entry.label}</span>
|
||||
<span class="ge-float-btns">
|
||||
<button class="ge-float-dock" type="button" title="Dock panel" aria-label="Dock panel">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-float-body"></div>`;
|
||||
win.querySelector('.ge-float-body').appendChild(panelEl);
|
||||
panelEl.hidden = false;
|
||||
document.body.appendChild(win);
|
||||
const r = groupEl.getBoundingClientRect();
|
||||
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 240, r.left - 260))}px`;
|
||||
win.style.top = `${Math.max(8, r.top)}px`;
|
||||
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
|
||||
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
|
||||
const itemEl = tabsEl?.querySelector(`.ge-dock-tab-item[data-panel="${entry.id}"]`);
|
||||
itemEl?.remove();
|
||||
const wasActive = activeId === entry.id;
|
||||
win.querySelector('.ge-float-dock').addEventListener('click', () => {
|
||||
const bodyEl = groupEl.querySelector('.ge-dock-panels');
|
||||
if (bodyEl) bodyEl.appendChild(panelEl); else groupEl.appendChild(panelEl);
|
||||
panelEl.hidden = activeId !== entry.id;
|
||||
win.remove();
|
||||
if (tabsEl) tabsEl.appendChild(makeTabItem(group, entry, groupEl));
|
||||
if (wasActive) activateInGroup(group, entry.id);
|
||||
detachPlacers.get(group.name)?.();
|
||||
syncTabsOverflow(groupEl);
|
||||
});
|
||||
if (wasActive) {
|
||||
const next = group.entries.find(e => {
|
||||
const p = groupEl.querySelector(`.ge-dock-panel[data-panel="${e.id}"]`);
|
||||
return p && !p.closest('.ge-float-window');
|
||||
});
|
||||
if (next) activateInGroup(group, next.id);
|
||||
}
|
||||
syncTabsOverflow(groupEl);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
const tabbar = document.createElement('div');
|
||||
tabbar.className = 'ge-dock-tabbar';
|
||||
const select = document.createElement('select');
|
||||
select.className = 'ge-dock-select';
|
||||
select.setAttribute('aria-label', 'Editor panel');
|
||||
const optgroups = new Map();
|
||||
entries.forEach(({ id, label, group }) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = label;
|
||||
if (group) {
|
||||
let optgroup = optgroups.get(group);
|
||||
if (!optgroup) {
|
||||
optgroup = document.createElement('optgroup');
|
||||
optgroup.label = group;
|
||||
optgroups.set(group, optgroup);
|
||||
select.appendChild(optgroup);
|
||||
}
|
||||
optgroup.appendChild(option);
|
||||
} else {
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
tabbar.appendChild(select);
|
||||
const panels = document.createElement('div');
|
||||
panels.className = 'ge-dock-panels';
|
||||
entries.forEach(({ id, element }) => {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ge-dock-panel';
|
||||
panel.dataset.panel = id;
|
||||
if (element) panel.appendChild(element);
|
||||
panels.appendChild(panel);
|
||||
});
|
||||
dock.appendChild(tabbar);
|
||||
dock.appendChild(panels);
|
||||
|
||||
const applyFlat = (id) => {
|
||||
activeId = id;
|
||||
panels.querySelectorAll('.ge-dock-panel').forEach(p => { p.hidden = p.dataset.panel !== id; });
|
||||
const group = groupOfId.get(id);
|
||||
if (group) activeByGroup.set(group.name, id);
|
||||
persist();
|
||||
};
|
||||
select.addEventListener('change', () => applyFlat(select.value));
|
||||
const initial = defaultId || entries[0]?.id || null;
|
||||
if (initial) { select.value = initial; applyFlat(initial); }
|
||||
return { dock, show, get activeId() { return activeId; } };
|
||||
}
|
||||
|
||||
// Stacked mode: one `.ge-dock-group` per panel group, with a drag
|
||||
// divider between groups (skip a leading divider).
|
||||
groups.forEach((group, groupIndex) => {
|
||||
if (groupIndex > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'ge-dock-divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
divider.setAttribute('aria-orientation', 'horizontal');
|
||||
divider.title = 'Drag to resize panel group';
|
||||
dock.appendChild(divider);
|
||||
wireDivider(divider, groupIndex);
|
||||
}
|
||||
|
||||
const groupEl = document.createElement('div');
|
||||
groupEl.className = 'ge-dock-group';
|
||||
groupEl.dataset.group = group.name;
|
||||
if (group.size) {
|
||||
groupEl.style.flex = `0 0 ${group.size}px`;
|
||||
groupEl.style.height = `${group.size}px`;
|
||||
}
|
||||
if (group.collapsed) groupEl.classList.add('is-collapsed');
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ge-dock-group-head';
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'ge-dock-tabs';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
group.entries.forEach(entry => {
|
||||
tabs.appendChild(makeTabItem(group, entry, groupEl));
|
||||
});
|
||||
|
||||
const collapse = document.createElement('button');
|
||||
collapse.type = 'button';
|
||||
collapse.className = 'ge-dock-collapse';
|
||||
collapse.title = 'Collapse panel group';
|
||||
collapse.setAttribute('aria-label', 'Collapse panel group');
|
||||
collapse.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
collapse.addEventListener('click', () => {
|
||||
const collapsed = groupEl.classList.toggle('is-collapsed');
|
||||
collapse.title = collapsed ? 'Expand panel group' : 'Collapse panel group';
|
||||
collapse.setAttribute('aria-label', collapse.title);
|
||||
});
|
||||
|
||||
// Overflow affordance: when a group's tabs do not fit the column,
|
||||
// show a chevron that opens a menu of every tab so none become
|
||||
// unreachable or unreadable (Photoshop-style tab overflow).
|
||||
const tabsMore = document.createElement('button');
|
||||
tabsMore.type = 'button';
|
||||
tabsMore.className = 'ge-dock-tabs-more';
|
||||
tabsMore.title = 'More panels';
|
||||
tabsMore.setAttribute('aria-label', 'More panels');
|
||||
tabsMore.hidden = true;
|
||||
tabsMore.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="18" cy="12" r="1.9"/></svg>';
|
||||
tabsMore.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openTabsMenu(tabsMore, group, groupEl);
|
||||
});
|
||||
|
||||
|
||||
head.appendChild(tabs);
|
||||
head.appendChild(tabsMore);
|
||||
head.appendChild(collapse);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ge-dock-panels';
|
||||
group.entries.forEach(entry => {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ge-dock-panel';
|
||||
panel.dataset.panel = entry.id;
|
||||
if (entry.element) panel.appendChild(entry.element);
|
||||
body.appendChild(panel);
|
||||
});
|
||||
|
||||
groupEl.appendChild(head);
|
||||
groupEl.appendChild(body);
|
||||
dock.appendChild(groupEl);
|
||||
groupEls.set(group.name, groupEl);
|
||||
syncTabsOverflow(groupEl);
|
||||
});
|
||||
|
||||
// Re-evaluate tab overflow whenever the column width changes, and keep any
|
||||
// torn-off (floating) panels on-screen after a viewport resize so they never
|
||||
// drift off the edge or vanish.
|
||||
window.addEventListener('resize', () => {
|
||||
groupEls.forEach(el => syncTabsOverflow(el));
|
||||
document.querySelectorAll('.ge-float-window').forEach((w) => {
|
||||
const r = w.getBoundingClientRect();
|
||||
const maxL = Math.max(8, window.innerWidth - r.width - 8);
|
||||
const maxT = Math.max(8, window.innerHeight - 48);
|
||||
w.style.left = Math.min(Math.max(8, r.left), maxL) + 'px';
|
||||
w.style.top = Math.min(Math.max(8, r.top), maxT) + 'px';
|
||||
});
|
||||
});
|
||||
|
||||
// The dock is built before it is attached to the DOM (so its width is 0 at
|
||||
// build time and the overflow check above cannot see the real column width).
|
||||
// Observe size changes to recompute overflow once it is laid out and whenever
|
||||
// the panel is resized, collapsed or expanded — otherwise a tab strip that
|
||||
// overflows keeps the chevron hidden and clips its trailing labels.
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => groupEls.forEach(el => syncTabsOverflow(el)));
|
||||
ro.observe(dock);
|
||||
}
|
||||
requestAnimationFrame(() => groupEls.forEach(el => syncTabsOverflow(el)));
|
||||
|
||||
function syncTabsOverflow(groupEl) {
|
||||
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
|
||||
const moreEl = groupEl.querySelector('.ge-dock-tabs-more');
|
||||
if (!tabsEl || !moreEl) return;
|
||||
const items = Array.from(tabsEl.querySelectorAll('.ge-dock-tab-item'));
|
||||
// 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;
|
||||
if (!overflowing) return;
|
||||
// Photoshop-style tab overflow: rather than clipping a label mid-word
|
||||
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
|
||||
// fit whole and let the chevron expose the rest. The active tab is always
|
||||
// kept visible so the current panel never loses its anchor.
|
||||
const active = tabsEl.querySelector('.ge-dock-tab.active');
|
||||
const activeItem = active ? active.closest('.ge-dock-tab-item') : null;
|
||||
const limit = tabsEl.clientWidth;
|
||||
let gap = 0;
|
||||
try { gap = parseFloat(getComputedStyle(tabsEl).columnGap) || 0; } catch {}
|
||||
// Keep the leading run of tabs that fits, then tuck the trailing tabs
|
||||
// (and everything after the first that no longer fits) into the chevron.
|
||||
// The active tab is always kept visible so the current panel stays anchored.
|
||||
let used = 0;
|
||||
let cutting = false;
|
||||
items.forEach(item => {
|
||||
const w = item.getBoundingClientRect().width;
|
||||
if (item === activeItem || (!cutting && used + w <= limit + 0.5)) {
|
||||
used += w + gap;
|
||||
} else {
|
||||
cutting = true;
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let tabsMenu = null;
|
||||
function closeTabsMenu() {
|
||||
if (!tabsMenu) return;
|
||||
tabsMenu.remove();
|
||||
tabsMenu = null;
|
||||
document.removeEventListener('pointerdown', onTabsMenuDocDown, true);
|
||||
}
|
||||
function onTabsMenuDocDown(e) {
|
||||
if (tabsMenu && !tabsMenu.contains(e.target) && !e.target.closest?.('.ge-dock-tabs-more')) closeTabsMenu();
|
||||
}
|
||||
function openTabsMenu(anchor, group, groupEl) {
|
||||
closeTabsMenu();
|
||||
tabsMenu = document.createElement('div');
|
||||
tabsMenu.className = 'ge-dock-tabs-menu';
|
||||
group.entries.forEach(entry => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'ge-dock-tabs-menu-item';
|
||||
item.textContent = entry.label;
|
||||
item.addEventListener('click', () => {
|
||||
groupEl.classList.remove('is-collapsed');
|
||||
activateInGroup(group, entry.id);
|
||||
syncTabsOverflow(groupEl);
|
||||
closeTabsMenu();
|
||||
});
|
||||
tabsMenu.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(tabsMenu);
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
|
||||
tabsMenu.style.top = `${r.bottom + 2}px`;
|
||||
document.addEventListener('pointerdown', onTabsMenuDocDown, true);
|
||||
}
|
||||
|
||||
function wireDivider(divider, groupIndex) {
|
||||
const groupEl = () => dock.querySelectorAll('.ge-dock-group')[groupIndex - 1];
|
||||
let startY = 0;
|
||||
let startH = 0;
|
||||
let resizing = false;
|
||||
divider.addEventListener('pointerdown', (event) => {
|
||||
const target = groupEl();
|
||||
if (!target) return;
|
||||
resizing = true;
|
||||
startY = event.clientY;
|
||||
startH = target.getBoundingClientRect().height;
|
||||
divider.setPointerCapture(event.pointerId);
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
event.preventDefault();
|
||||
});
|
||||
divider.addEventListener('pointermove', (event) => {
|
||||
if (!resizing) return;
|
||||
const target = groupEl();
|
||||
if (!target) return;
|
||||
const next = Math.max(28, startH + (event.clientY - startY));
|
||||
target.style.flex = `1 1 ${Math.round(next)}px`;
|
||||
target.style.height = `${Math.round(next)}px`;
|
||||
});
|
||||
const end = (event) => {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
document.body.style.cursor = '';
|
||||
try { divider.releasePointerCapture?.(event.pointerId); } catch {}
|
||||
};
|
||||
divider.addEventListener('pointerup', end);
|
||||
divider.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// Move a panel group out of the dock into a floating, draggable
|
||||
// window; the window offers a "Dock" control that returns the exact
|
||||
// same `.ge-dock-group` node (DOM identity preserved) to its original
|
||||
// position in the stack.
|
||||
function detachGroup(group, groupEl, dockEl) {
|
||||
if (groupEl.classList.contains('is-floating')) return;
|
||||
const prevDivider = groupEl.previousElementSibling;
|
||||
const hadDivider = !!prevDivider && prevDivider.classList.contains('ge-dock-divider');
|
||||
if (hadDivider) prevDivider.hidden = true;
|
||||
const dockIndex = Array.from(dockEl.querySelectorAll('.ge-dock-group')).indexOf(groupEl);
|
||||
groupEl.classList.add('is-floating');
|
||||
const win = document.createElement('div');
|
||||
win.className = 'ge-float-window';
|
||||
win.dataset.group = group.name;
|
||||
win.innerHTML = `
|
||||
<div class="ge-float-head">
|
||||
<span class="ge-float-title">${group.name}</span>
|
||||
<span class="ge-float-btns">
|
||||
<button class="ge-float-dock" type="button" title="Dock panel group" aria-label="Dock panel group">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-float-body"></div>`;
|
||||
win.querySelector('.ge-float-body').appendChild(groupEl);
|
||||
document.body.appendChild(win);
|
||||
const r = groupEl.getBoundingClientRect();
|
||||
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 260, r.left - 60))}px`;
|
||||
win.style.top = `${Math.max(8, r.top)}px`;
|
||||
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
|
||||
win.querySelector('.ge-float-dock').addEventListener('click', () => {
|
||||
const groupsNow = Array.from(dockEl.querySelectorAll('.ge-dock-group'));
|
||||
const anchor = groupsNow[dockIndex] || null;
|
||||
if (anchor) dockEl.insertBefore(groupEl, anchor); else dockEl.appendChild(groupEl);
|
||||
if (hadDivider) prevDivider.hidden = false;
|
||||
groupEl.classList.remove('is-floating');
|
||||
win.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function makeFloatDraggable(win, handle) {
|
||||
if (!handle) return;
|
||||
handle.style.touchAction = 'none';
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
const r0 = win.getBoundingClientRect();
|
||||
try { handle.setPointerCapture(e.pointerId); } catch {}
|
||||
handle.style.cursor = 'grabbing';
|
||||
const onMove = (ev) => {
|
||||
win.style.left = `${Math.max(0, Math.min(window.innerWidth - 80, r0.left + (ev.clientX - startX)))}px`;
|
||||
win.style.top = `${Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)))}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
try { handle.releasePointerCapture(e.pointerId); } catch {}
|
||||
handle.style.cursor = '';
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
groupEls.forEach((groupEl, name) => {
|
||||
const group = groups.find(g => g.name === name);
|
||||
const id = activeByGroup.get(name) || group?.entries[0]?.id;
|
||||
if (id) activateInGroup(group, id);
|
||||
});
|
||||
return {
|
||||
dock,
|
||||
show,
|
||||
get activeId() { return activeId; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Contextual tool options bar — a Photoshop-style strip that sits
|
||||
* directly under the topbar and shows quick controls for the active
|
||||
* tool.
|
||||
*
|
||||
* This is a *mirror* of the authoritative controls in the right-hand
|
||||
* panel: it never owns state. Every value is delegated to the existing
|
||||
* input element (by selector) so the two views can never disagree —
|
||||
* editing here re-dispatches the same `input`/`change` event the real
|
||||
* control listens for, and the real control pushes updates back into
|
||||
* the bar.
|
||||
*
|
||||
* 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 { state } from '../state.js';
|
||||
|
||||
// Per-tool option sources. Each value is a CSS selector for an existing
|
||||
// control inside the right panel; `null` means "no such control".
|
||||
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' },
|
||||
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 },
|
||||
};
|
||||
|
||||
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']];
|
||||
|
||||
function resolve(selector) {
|
||||
if (!selector) return null;
|
||||
return selector.startsWith('#')
|
||||
? document.getElementById(selector.slice(1))
|
||||
: document.querySelector(selector);
|
||||
}
|
||||
|
||||
function makeRange(label, sourceSelector, { suffix = '' } = {}) {
|
||||
const source = resolve(sourceSelector);
|
||||
const value = source ? source.value : '0';
|
||||
const min = source?.getAttribute('min') ?? '0';
|
||||
const max = source?.getAttribute('max') ?? '100';
|
||||
const step = source?.getAttribute('step') ?? '1';
|
||||
return `
|
||||
<div class="ge-opt" data-opt="range">
|
||||
<label class="ge-opt-label">${label}<span class="ge-opt-value" data-opt-value>--</span></label>
|
||||
<input type="range" data-opt-range data-src="${sourceSelector}" min="${min}" max="${max}" step="${step}" value="${value}" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function makeSelect(label, sourceSelector) {
|
||||
const source = resolve(sourceSelector);
|
||||
const options = source
|
||||
? [...source.options].map(o => `<option value="${o.value}">${o.textContent}</option>`).join('')
|
||||
: '';
|
||||
const value = source ? source.value : '';
|
||||
return `
|
||||
<div class="ge-opt" data-opt="select">
|
||||
<label class="ge-opt-label">${label}</label>
|
||||
<select data-opt-select data-src="${sourceSelector}">${options}</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function makeAutoSelect() {
|
||||
return `
|
||||
<label class="ge-opt ge-auto-select-option" data-opt="check">
|
||||
<input type="checkbox" id="ge-auto-select-layer" />
|
||||
<span class="ge-opt-label">Auto-Select</span>
|
||||
</label>
|
||||
<div class="ge-opt" data-opt="select">
|
||||
<label class="ge-opt-label" for="ge-auto-select-target">Target</label>
|
||||
<select id="ge-auto-select-target">
|
||||
<option value="layer">Layer</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function makeModes(sectionSelector) {
|
||||
const buttons = MODE_LABELS.map(([mode, text]) =>
|
||||
`<button type="button" class="ge-opt-mode" data-opt-mode="${mode}" data-section="${sectionSelector}">${text}</button>`
|
||||
).join('');
|
||||
return `<div class="ge-opt" data-opt="modes"><span class="ge-opt-label">Mode</span><span class="ge-opt-modes">${buttons}</span></div>`;
|
||||
}
|
||||
|
||||
function buildToolControls(toolId) {
|
||||
const parts = [];
|
||||
|
||||
if (BRUSH_TOOLS[toolId]) {
|
||||
const src = BRUSH_TOOLS[toolId];
|
||||
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
|
||||
parts.push(makeRange('Opacity', src.opacity));
|
||||
parts.push(makeRange('Flow', src.flow));
|
||||
parts.push(makeRange('Softness', src.softness));
|
||||
} else if (SELECTION_TOOLS[toolId]) {
|
||||
const src = SELECTION_TOOLS[toolId];
|
||||
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 (toolId === 'move') {
|
||||
parts.push(makeAutoSelect());
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return '';
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function valueLabelOf(source, value) {
|
||||
// Prefer the sibling label the right panel already maintains so the
|
||||
// two surfaces always show the same formatted number.
|
||||
const row = source?.closest?.('.ge-eraser-row, .ge-control-row');
|
||||
const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]');
|
||||
const text = label?.textContent?.trim();
|
||||
return text || (typeof value === 'string' ? value : String(value));
|
||||
}
|
||||
|
||||
/** Refresh every mirrored control inside `bar` from its source. */
|
||||
export function syncOptionsBar(bar) {
|
||||
if (!bar) return;
|
||||
bar.querySelectorAll('[data-src]').forEach(el => {
|
||||
const source = resolve(el.dataset.src);
|
||||
if (!source) return;
|
||||
if (el.tagName === 'SELECT') { el.value = source.value; return; }
|
||||
el.value = source.value;
|
||||
const field = el.closest('.ge-opt');
|
||||
const out = field?.querySelector('[data-opt-value]');
|
||||
if (out) out.textContent = valueLabelOf(source, source.value);
|
||||
});
|
||||
// Selection mode buttons.
|
||||
bar.querySelectorAll('[data-opt-mode]').forEach(btn => {
|
||||
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
|
||||
btn.classList.toggle('active', !!source && source.classList.contains('active'));
|
||||
});
|
||||
// Move-tool auto-select (owned here rather than in the right panel).
|
||||
const autoSelect = bar.querySelector('#ge-auto-select-layer');
|
||||
if (autoSelect) autoSelect.checked = !!state.autoSelectLayer;
|
||||
const target = bar.querySelector('#ge-auto-select-target');
|
||||
if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer';
|
||||
}
|
||||
|
||||
/** Render the controls for `toolId` and wire delegation + mirroring. */
|
||||
export function renderOptionsBar(bar, toolId) {
|
||||
if (!bar) return;
|
||||
bar.dataset.optionsTool = toolId;
|
||||
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_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);
|
||||
|
||||
const icon = bar.querySelector('.ge-options-tool-icon');
|
||||
const name = bar.querySelector('.ge-options-tool-name');
|
||||
const meta = TOOLS[toolId];
|
||||
if (name) name.textContent = meta?.label || toolId || '';
|
||||
if (icon) icon.innerHTML = meta?.icon || '';
|
||||
|
||||
const body = bar.querySelector('.ge-options-body');
|
||||
if (body) body.innerHTML = buildToolControls(toolId);
|
||||
|
||||
// Mirror right-panel changes back into the bar.
|
||||
bar.querySelectorAll('[data-src]').forEach(el => {
|
||||
const source = resolve(el.dataset.src);
|
||||
if (!source || source.dataset.geOptMirror) return;
|
||||
source.dataset.geOptMirror = '1';
|
||||
source.addEventListener('input', () => syncOptionsBar(bar));
|
||||
source.addEventListener('change', () => syncOptionsBar(bar));
|
||||
});
|
||||
|
||||
syncOptionsBar(bar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the (empty) options-bar shell. Call `renderOptionsBar` after
|
||||
* the right-panel controls exist so delegation can resolve them.
|
||||
*/
|
||||
export function buildOptionsBar() {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'ge-options-bar';
|
||||
bar.id = 'ge-options-bar';
|
||||
bar.innerHTML = `
|
||||
<div class="ge-options-tool">
|
||||
<span class="ge-options-tool-icon" aria-hidden="true"></span>
|
||||
<span class="ge-options-tool-name"></span>
|
||||
</div>
|
||||
<div class="ge-options-sep" aria-hidden="true"></div>
|
||||
<div class="ge-options-body"></div>
|
||||
<button type="button" class="ge-options-more" id="ge-options-more" title="More options" aria-label="More options" aria-haspopup="true" aria-expanded="false" hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
|
||||
</button>`;
|
||||
|
||||
const more = bar.querySelector('#ge-options-more');
|
||||
more.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const open = bar.classList.toggle('ge-more-open');
|
||||
more.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// Delegation: ranges + selects write through to their real control.
|
||||
bar.addEventListener('input', event => {
|
||||
const el = event.target.closest('[data-opt-range]');
|
||||
if (!el) return;
|
||||
const source = resolve(el.dataset.src);
|
||||
if (!source) return;
|
||||
source.value = el.value;
|
||||
source.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]');
|
||||
if (out) out.textContent = valueLabelOf(source, source.value);
|
||||
});
|
||||
bar.addEventListener('change', event => {
|
||||
if (event.target.id === 'ge-auto-select-layer') {
|
||||
state.autoSelectLayer = event.target.checked;
|
||||
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
if (event.target.id === 'ge-auto-select-target') {
|
||||
state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer';
|
||||
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
const el = event.target.closest('[data-opt-select]');
|
||||
if (!el) return;
|
||||
const source = resolve(el.dataset.src);
|
||||
if (!source) return;
|
||||
source.value = el.value;
|
||||
source.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
bar.addEventListener('click', event => {
|
||||
const btn = event.target.closest('[data-opt-mode]');
|
||||
if (!btn) return;
|
||||
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
|
||||
source?.click();
|
||||
syncOptionsBar(bar);
|
||||
});
|
||||
|
||||
return bar;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
||||
<div><kbd>C</kbd> Crop</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.gradient}</kbd> Gradient</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.zoom}</kbd> Zoom <span style="opacity:0.5">(Alt-click = zoom out)</span></div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
@@ -43,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>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
|
||||
<div><kbd>Ctrl</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">
|
||||
@@ -62,6 +64,13 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>]</kbd> Brush size +</div>
|
||||
<div>Drag tolerance slider → live wand retune</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Touch</h5>
|
||||
<div>Pinch → Zoom</div>
|
||||
<div>Two-finger drag → Pan</div>
|
||||
<div>Two-finger tap → Undo</div>
|
||||
<div>Three-finger tap → Redo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
|
||||
`;
|
||||
@@ -81,6 +90,7 @@ export function historyPanelHTML(historyIcon) {
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-history-snapshots" id="ge-history-snapshots"></div>
|
||||
<div class="ge-history-list" id="ge-history-list"></div>
|
||||
`;
|
||||
}
|
||||
@@ -95,6 +105,7 @@ export function canvasSizePromptHTML() {
|
||||
<div class="modal-content ge-canvas-prompt">
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
||||
<div class="modal-body">
|
||||
<div class="ge-canvas-preview" hidden><img id="ge-canvas-prompt-preview" alt="Image preview" /></div>
|
||||
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
|
||||
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
|
||||
@@ -126,6 +137,7 @@ export function canvasSizePromptHTML() {
|
||||
<div class="ge-canvas-resize-options">
|
||||
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
|
||||
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
|
||||
<label class="ge-canvas-resolution-field" hidden><span class="ge-canvas-control-label"><span>Resolution</span></span><input type="number" id="ge-canvas-prompt-resolution" min="1" max="1000" value="72" /><span class="ge-canvas-resolution-unit">px/inch</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer ge-canvas-prompt-footer">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* values).
|
||||
*
|
||||
* @param {{
|
||||
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string,
|
||||
* controlsHTML: (ctx: {color, brushSize, wandTolerance, bucketTolerance, cropAspectPreset}) => string,
|
||||
* layerPanelHTML: () => string,
|
||||
* }} build
|
||||
*
|
||||
@@ -22,13 +22,19 @@
|
||||
* controls: HTMLDivElement,
|
||||
* layerPanel: HTMLDivElement,
|
||||
* panelResize: HTMLDivElement,
|
||||
* dock: {dock: HTMLDivElement, show: (id: string) => void}|null,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { buildDock } from './dock.js';
|
||||
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'ge-right-panel';
|
||||
// Desktop: present Tool Options + Layers as tabs in a dock. Mobile
|
||||
// (≤ 700 px) keeps the existing bottom-sheet model, where the two
|
||||
// panels are independent sheets, so the dock is bypassed entirely.
|
||||
const useDock = window.innerWidth > 700;
|
||||
|
||||
// Controls section.
|
||||
const controls = document.createElement('div');
|
||||
@@ -66,8 +72,11 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
color: state.color,
|
||||
brushSize: state.brushSize,
|
||||
wandTolerance: state.wandTolerance,
|
||||
bucketTolerance: state.bucketTolerance,
|
||||
cropAspectPreset: state.cropAspectPreset,
|
||||
eyedropperSampleSize: state.eyedropperSampleSize,
|
||||
});
|
||||
rightPanel.appendChild(controls);
|
||||
if (!useDock) rightPanel.appendChild(controls);
|
||||
// Mobile only (≤ 700 px — matches the .ge-editor-body column-stack
|
||||
// breakpoint): the right panel becomes a transformed bottom-sheet,
|
||||
// so any position:fixed descendant gets trapped by the transform
|
||||
@@ -99,7 +108,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
const layerPanel = document.createElement('div');
|
||||
layerPanel.className = 'ge-layers';
|
||||
layerPanel.innerHTML = layerPanelHTML();
|
||||
rightPanel.appendChild(layerPanel);
|
||||
if (!useDock) rightPanel.appendChild(layerPanel);
|
||||
// Mobile: tap the header grab handle or swipe up/down to toggle
|
||||
// the layers sheet between peek and expanded. The peek state
|
||||
// always shows the active layer so users never lose access to it.
|
||||
@@ -164,6 +173,77 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile panel access (≤ 700 px). The desktop dock is bypassed on
|
||||
// phones, which left the whole studio panel set (Swatches, Gradients,
|
||||
// Patterns, Properties, Adjustments, Histogram, Info, Channels, Paths,
|
||||
// Character, Navigator, History, Actions) unreachable — there was no way
|
||||
// to add an adjustment or pick a colour from the sheet. Mount the same
|
||||
// entries in a compact `<select>` overlay reachable from a "Panels"
|
||||
// button in the layers header, without touching the peek/expand model.
|
||||
let mobilePanels = null;
|
||||
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
|
||||
const mDock = buildDock({
|
||||
entries: extraPanels,
|
||||
persistKey: 'ge-dock-active',
|
||||
defaultId: null,
|
||||
compact: true,
|
||||
});
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ge-mobile-panels';
|
||||
overlay.hidden = true;
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ge-mobile-panels-head';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'ge-mobile-panels-title';
|
||||
title.textContent = 'Panels';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'ge-mobile-panels-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close panels');
|
||||
closeBtn.textContent = '✕';
|
||||
head.append(title, closeBtn);
|
||||
overlay.append(head, mDock.dock);
|
||||
rightPanel.appendChild(overlay);
|
||||
closeBtn.addEventListener('click', () => { overlay.hidden = true; });
|
||||
|
||||
const hdr = layerPanel.querySelector('.ge-layers-header');
|
||||
if (hdr) {
|
||||
const panelsBtn = document.createElement('button');
|
||||
panelsBtn.type = 'button';
|
||||
panelsBtn.className = 'ge-layers-panels-btn';
|
||||
panelsBtn.textContent = 'Panels';
|
||||
panelsBtn.title = 'Show panels';
|
||||
panelsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
overlay.hidden = !overlay.hidden;
|
||||
});
|
||||
hdr.appendChild(panelsBtn);
|
||||
}
|
||||
mobilePanels = overlay;
|
||||
}
|
||||
|
||||
// Desktop dock: Tool Options + Layers as tabs (PhotoCraft-parity).
|
||||
// The panels are only re-parented, so every existing selector and
|
||||
// listener keeps working; switching tabs just toggles `hidden`.
|
||||
let dock = null;
|
||||
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
|
||||
dock = buildDock({
|
||||
entries: [
|
||||
...(Array.isArray(extraPanels) ? extraPanels : []),
|
||||
],
|
||||
defaultId: (Array.isArray(extraPanels) && extraPanels[0]?.id) || null,
|
||||
persistKey: 'ge-dock-active',
|
||||
// Narrow desktop columns get the compact selector fallback; the
|
||||
// multi-group stack needs a bit more room to be usable.
|
||||
compact: window.innerWidth <= 820,
|
||||
});
|
||||
rightPanel.appendChild(dock.dock);
|
||||
// Layers stays pinned below the dock so the layer list is always
|
||||
// visible while editing (Photoshop-style), regardless of the active
|
||||
// dock tab. Only the tool options + studio panels live in tabs.
|
||||
rightPanel.appendChild(layerPanel);
|
||||
}
|
||||
|
||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||
// left to widen, right to narrow. Persists chosen width in
|
||||
// localStorage so it survives reopens. (Earlier version was a
|
||||
@@ -205,5 +285,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return { rightPanel, controls, layerPanel, panelResize };
|
||||
return { rightPanel, controls, layerPanel, panelResize, dock };
|
||||
}
|
||||
|
||||
@@ -6,85 +6,264 @@
|
||||
* mask plumbing, etc.) stays in the caller and arrives here as the
|
||||
* `onSelectTool` callback.
|
||||
*
|
||||
* Layout mirrors PhotoCraft's rail: ordered sections, with related tools
|
||||
* collapsed into a single flyout slot (face = current member, caret opens
|
||||
* the family). Selecting a flyout member promotes it to the slot face.
|
||||
*
|
||||
* @param {{
|
||||
* currentTool: string,
|
||||
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand'|'sam') => void,
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
* @returns {{
|
||||
* toolbar: HTMLDivElement,
|
||||
* toolKeyMap: Record<string,string>,
|
||||
* syncActive: (toolId: string) => void,
|
||||
* }}
|
||||
*/
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
import { loadBrushPresets } from '../brush-presets.js';
|
||||
|
||||
const CLEAR_X = '<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>';
|
||||
const CARET = '<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
|
||||
|
||||
export const TOOLS = {
|
||||
move: { label: 'Move', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>' },
|
||||
hand: { label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
|
||||
zoom: { label: 'Zoom', 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="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>' },
|
||||
crop: { label: 'Crop', 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="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>' },
|
||||
transform: { label: 'Transform', icon: '<svg width="18" height="18" 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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
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>' },
|
||||
path: { label: 'Pen Path', 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="M4 20h4M20 4v4"/><circle cx="4" cy="20" r="2"/><circle cx="20" cy="4" r="2"/></svg>' },
|
||||
wand: { label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
|
||||
quickselect: { label: 'Quick Select', 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 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4"/><path d="M12 8a4 4 0 0 1 0 8"/></svg>' },
|
||||
objectselect: { label: 'Object Select', 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="2" stroke-dasharray="3 3"/><path d="M9 9h6v6H9z"/></svg>' },
|
||||
sam: { label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
inpaint: { label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
rembg: { label: 'Bg Remove', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>' },
|
||||
sharpen: { label: 'Sharpen', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>' },
|
||||
};
|
||||
|
||||
// Ordered sections. A string is a single-tool slot; an array is a flyout
|
||||
// slot whose first member is the default face.
|
||||
const SECTIONS = [
|
||||
['move'],
|
||||
[['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']],
|
||||
[['crop', 'transform'], ['brush', 'eraser'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn'], ['gradient', 'bucket'], 'eyedropper'],
|
||||
[['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']],
|
||||
[['hand', 'zoom']],
|
||||
];
|
||||
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢' },
|
||||
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
|
||||
{ id: 'pen', label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
|
||||
];
|
||||
const toolKeyMap = {};
|
||||
for (const t of tools) {
|
||||
t.key = TOOL_SHORTCUTS[t.id];
|
||||
if (t.sep) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
sep.textContent = t.label;
|
||||
toolbar.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
if (t.key) toolKeyMap[t.key.toLowerCase()] = t.id;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
|
||||
btn.dataset.tool = t.id;
|
||||
btn.title = t.label + (t.key ? ` (${t.key})` : '');
|
||||
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
|
||||
// Heavy 4-point AI star marker for AI-backed tools — sits just to
|
||||
// the left of the icon so the user can spot AI vs local tools at a
|
||||
// glance now that the "AI Tools" separator is gone.
|
||||
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
|
||||
const slots = [];
|
||||
|
||||
for (const id of Object.keys(TOOLS)) {
|
||||
const key = TOOL_SHORTCUTS[id];
|
||||
if (key) toolKeyMap[key.toLowerCase()] = id;
|
||||
}
|
||||
|
||||
const setInner = (btn, toolId) => {
|
||||
const t = TOOLS[toolId];
|
||||
btn.dataset.tool = toolId;
|
||||
btn.title = t.label + (TOOL_SHORTCUTS[toolId] ? ` (${TOOL_SHORTCUTS[toolId]})` : '');
|
||||
if (toolId === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
|
||||
if (toolId === 'path') btn.title += ' - Click to add anchors; double-click or press Enter to finish';
|
||||
btn.classList.toggle('is-ai', !!t.ai);
|
||||
// Selection-clear badge — rendered only for tools that can hold a
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
|
||||
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
|
||||
'</span>'
|
||||
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
|
||||
const clearTitle = toolId === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = ['marquee', 'lasso', 'quickselect', 'objectselect', 'wand', 'sam'].includes(toolId)
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + toolId + '">' + CLEAR_X + '</span>'
|
||||
: '';
|
||||
btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span><span class="ge-tool-label">${t.label}</span>${clearBadge}`;
|
||||
// Clear-badge click stops propagation so the tool itself doesn't
|
||||
// toggle; the actual clear is handled by the caller.
|
||||
btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span>${clearBadge}`;
|
||||
btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
onClearSelection(ev.currentTarget.dataset.clearTool);
|
||||
});
|
||||
btn.addEventListener('click', () => onSelectTool(t.id, btn, toolbar));
|
||||
toolbar.appendChild(btn);
|
||||
};
|
||||
|
||||
const closeAllFlyouts = () => {
|
||||
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
||||
};
|
||||
|
||||
const openFlyout = (slotEl, face) => {
|
||||
const isOpen = slotEl.classList.contains('open');
|
||||
closeAllFlyouts();
|
||||
if (isOpen) return;
|
||||
slotEl.classList.add('open');
|
||||
const flyout = slotEl.querySelector('.ge-tool-flyout');
|
||||
if (!flyout) return;
|
||||
const r = face.getBoundingClientRect();
|
||||
// The flyout is position:fixed, but an ancestor with a transform (the
|
||||
// gallery modal sets an identity matrix) becomes its containing block, so
|
||||
// viewport coords must be offset by that block's origin or the popup lands
|
||||
// far from the button.
|
||||
const cb = flyout.offsetParent;
|
||||
const cbRect = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
const cbX = cbRect.left + (cb ? cb.clientLeft : 0);
|
||||
const cbY = cbRect.top + (cb ? cb.clientTop : 0);
|
||||
if (window.innerWidth <= 820) {
|
||||
flyout.style.left = (Math.max(r.left, 4) - cbX) + 'px';
|
||||
flyout.style.top = (r.bottom + 4 - cbY) + 'px';
|
||||
} else {
|
||||
flyout.style.left = (r.right + 4 - cbX) + 'px';
|
||||
flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!toolbar.contains(ev.target)) closeAllFlyouts();
|
||||
});
|
||||
|
||||
let first = true;
|
||||
for (const section of SECTIONS) {
|
||||
if (!first) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
toolbar.appendChild(sep);
|
||||
}
|
||||
first = false;
|
||||
for (const entry of section) {
|
||||
const members = Array.isArray(entry) ? entry : [entry];
|
||||
const slotEl = document.createElement('div');
|
||||
slotEl.className = 'ge-tool-slot';
|
||||
const face = document.createElement('button');
|
||||
face.type = 'button';
|
||||
face.className = 'ge-tool-btn ge-tool-slot-face';
|
||||
const faceId = members.includes(currentTool) ? currentTool : members[0];
|
||||
setInner(face, faceId);
|
||||
face.addEventListener('click', () => {
|
||||
closeAllFlyouts();
|
||||
onSelectTool(face.dataset.tool, face, toolbar);
|
||||
});
|
||||
slotEl.appendChild(face);
|
||||
|
||||
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');
|
||||
tri?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
openFlyout(slotEl, face);
|
||||
});
|
||||
|
||||
const flyout = document.createElement('div');
|
||||
flyout.className = 'ge-tool-flyout';
|
||||
for (const id of members) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'ge-tool-btn ge-tool-flyout-item';
|
||||
setInner(item, id);
|
||||
// Keep `data-tool` unique to the slot face so `[data-tool="X"]`
|
||||
// selectors are unambiguous; flyout members expose the option id.
|
||||
item.dataset.toolOption = id;
|
||||
delete item.dataset.tool;
|
||||
item.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setInner(face, id);
|
||||
slot.current = id;
|
||||
closeAllFlyouts();
|
||||
onSelectTool(id, face, toolbar);
|
||||
});
|
||||
item.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${TOOLS[id].label}</span>`);
|
||||
flyout.appendChild(item);
|
||||
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.
|
||||
if (members.includes('brush')) {
|
||||
const brushPresets = loadBrushPresets();
|
||||
if (brushPresets.length) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-flyout-sep';
|
||||
flyout.appendChild(sep);
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'ge-tool-flyout-heading';
|
||||
heading.textContent = 'Brushes';
|
||||
flyout.appendChild(heading);
|
||||
for (const preset of brushPresets) {
|
||||
const presetItem = document.createElement('button');
|
||||
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>`);
|
||||
presetItem.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setInner(face, 'brush');
|
||||
slot.current = 'brush';
|
||||
onSelectTool('brush', face, toolbar);
|
||||
const picker = document.getElementById('ge-brush-preset');
|
||||
if (picker) {
|
||||
picker.value = preset.id;
|
||||
picker.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
closeAllFlyouts();
|
||||
});
|
||||
flyout.appendChild(presetItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
slotEl.appendChild(flyout);
|
||||
face.addEventListener('contextmenu', (ev) => {
|
||||
ev.preventDefault();
|
||||
openFlyout(slotEl, face);
|
||||
});
|
||||
}
|
||||
slots.push(slot);
|
||||
toolbar.appendChild(slotEl);
|
||||
}
|
||||
}
|
||||
return { toolbar, toolKeyMap };
|
||||
|
||||
// Foreground / background color swatches (Photoshop-style) pinned to
|
||||
// the bottom of the tool rail, with a swap control.
|
||||
const swatches = document.createElement('div');
|
||||
swatches.className = 'ge-tool-swatches';
|
||||
swatches.innerHTML = `
|
||||
<label class="ge-swatch ge-swatch-bg" title="Background color">
|
||||
<input type="color" id="ge-bg-color" value="#ffffff" aria-label="Background color" />
|
||||
</label>
|
||||
<label class="ge-swatch ge-swatch-fg" title="Foreground color">
|
||||
<input type="color" id="ge-fg-color" value="#000000" aria-label="Foreground color" />
|
||||
</label>
|
||||
<button type="button" class="ge-swatch-swap" id="ge-swap-colors" title="Swap foreground and background colors" aria-label="Swap colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4 3 8l4 4"/><path d="M3 8h13a4 4 0 0 1 0 8h-2"/><path d="M17 20l4-4-4-4"/></svg>
|
||||
</button>`;
|
||||
toolbar.appendChild(swatches);
|
||||
|
||||
const syncActive = (toolId) => {
|
||||
for (const slot of slots) {
|
||||
if (!slot.members.includes(toolId)) continue;
|
||||
if (slot.current !== toolId) setInner(slot.face, toolId);
|
||||
slot.current = toolId;
|
||||
slot.face.classList.add('active');
|
||||
slot.items.forEach(it => it.classList.toggle('active', (it.dataset.toolOption || it.dataset.tool) === toolId));
|
||||
}
|
||||
};
|
||||
|
||||
// Initial active state.
|
||||
syncActive(currentTool);
|
||||
|
||||
return { toolbar, toolKeyMap, syncActive };
|
||||
}
|
||||
|
||||
@@ -13,47 +13,82 @@ export function buildTopbar() {
|
||||
topBar.className = 'ge-topbar';
|
||||
topBar.innerHTML = `
|
||||
<div class="ge-topbar-left">
|
||||
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-undo" title="Undo">
|
||||
<span class="ge-stacked-glyph">↩</span>
|
||||
<span class="ge-stacked-label">UNDO</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-redo" title="Redo">
|
||||
<span class="ge-stacked-glyph">↪</span>
|
||||
<span class="ge-stacked-label">REDO</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
|
||||
<span class="ge-stacked-label">HISTORY</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
|
||||
<span class="ge-stacked-label">BEFORE</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">VIEW</span>
|
||||
<div class="ge-file-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-file-menu-btn" title="File actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">File</span>
|
||||
</button>
|
||||
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
|
||||
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
|
||||
<div class="ge-file-menu dropdown" id="ge-file-menu" hidden>
|
||||
<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>
|
||||
<div class="ge-menu-label">Save</div>
|
||||
<button class="dropdown-item-compact" data-file-action="save"><span>Save Over Original</span><span class="dropdown-shortcut">Ctrl+S</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="save-copy"><span>Save As Copy</span><span class="dropdown-shortcut">Ctrl+Shift+S</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="export"><span>Export Image…</span></button>
|
||||
<button class="dropdown-item-compact" data-file-action="export-psd"><span>Export Layers as PSD…</span></button>
|
||||
<div class="ge-menu-label">Project</div>
|
||||
<button class="dropdown-item-compact" data-file-action="save-project"><span>Save Project (.json)</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-edit-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-edit-menu-btn" title="Edit actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Edit</span>
|
||||
</button>
|
||||
<div class="ge-edit-menu dropdown" id="ge-edit-menu" hidden>
|
||||
<div class="ge-menu-label">History</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="undo"><span>Undo</span><span class="dropdown-shortcut">Ctrl+Z</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="redo"><span>Redo</span><span class="dropdown-shortcut">Ctrl+Shift+Z</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="history"><span>History…</span></button>
|
||||
<div class="ge-menu-label">Clipboard</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="copy-merged" title="Copy the flattened visible image to the clipboard"><span>Copy Merged</span><span class="dropdown-shortcut">Ctrl+Shift+C</span></button>
|
||||
<div class="ge-menu-label">Transform (active layer)</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="rotate-90" title="Rotate the active layer 90° CW"><span>Rotate 90° CW</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="rotate-180" title="Rotate the active layer 180°"><span>Rotate 180°</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="flip-h" title="Flip the active layer horizontally"><span>Flip Horizontal</span></button>
|
||||
<button class="dropdown-item-compact" data-edit-action="flip-v" title="Flip the active layer vertically"><span>Flip Vertical</span></button>
|
||||
<div class="ge-menu-label">Content-Aware</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="content-aware-move" title="Move the selection content and fill the hole it leaves behind"><span>Content-Aware Move…</span></button>
|
||||
<div class="ge-menu-label">Stroke</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="stroke" title="Stroke the active layer or selection"><span>Stroke…</span></button>
|
||||
<div class="ge-menu-label">Presets</div>
|
||||
<button class="dropdown-item-compact" data-edit-action="define-brush" title="Save the current brush settings as a reusable preset"><span>Define Brush Preset…</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-image-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">IMAGE</span>
|
||||
<span class="ge-stacked-label">Image</span>
|
||||
</button>
|
||||
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="image-adjustments" aria-haspopup="true" aria-expanded="false"><span>Adjustments</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="image-adjustments">
|
||||
<button class="dropdown-item-compact" data-image-action="desaturate" title="Desaturate"><span>Desaturate</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="auto-tone" title="Auto Tone"><span>Auto Tone</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="auto-contrast" title="Auto Contrast"><span>Auto Contrast</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="auto-color" title="Auto Color"><span>Auto Color</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="equalize" title="Equalize the histogram of the active layer"><span>Equalize</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="replace-color" title="Recolour pixels near a target colour"><span>Replace Color…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-brightness-contrast" title="Brightness/Contrast"><span>Brightness/Contrast…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-hue-saturation" title="Hue/Saturation"><span>Hue/Saturation…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-selective-color" title="Selective Color"><span>Selective Color…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-levels" title="Levels"><span>Levels…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-curves" title="Curves"><span>Curves…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-exposure" title="Exposure"><span>Exposure…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-develop" title="Develop (Camera Raw)"><span>Develop…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-vibrance" title="Vibrance"><span>Vibrance…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-color-balance" title="Color Balance"><span>Color Balance…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-black-white" title="Black & White"><span>Black & White…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-photo-filter" title="Photo Filter"><span>Photo Filter…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-channel-mixer" title="Channel Mixer"><span>Channel Mixer…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-invert" title="Invert"><span>Invert</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-posterize" title="Posterize"><span>Posterize…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-threshold" title="Threshold"><span>Threshold…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-gradient-map" title="Gradient Map"><span>Gradient Map…</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="adj-shadows-highlights" title="Shadows/Highlights"><span>Shadows/Highlights…</span></button>
|
||||
</div>
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas Size...</span>
|
||||
@@ -62,37 +97,131 @@ export function buildTopbar() {
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
|
||||
<span>Image Size...</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Transform</div>
|
||||
<div class="ge-filter-submenu-label">Trim</div>
|
||||
<button class="dropdown-item-compact" data-image-action="trim" title="Trim transparent edges from the active layer"><span>Trim…</span></button>
|
||||
<div class="ge-filter-submenu-label">Image Rotation (whole image)</div>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-90">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg></span>
|
||||
<span>Rotate 90° CW</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-ccw">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 3 3 9 9 9"/></svg></span>
|
||||
<span>Rotate 90° CCW</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-180">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></span>
|
||||
<span>Rotate 180°</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-arbitrary">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
|
||||
<span>Arbitrary…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="perspective-crop">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6l18-2-2 14-16-2z"/><circle cx="3" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="21" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.6" fill="currentColor" stroke="none"/><circle cx="3" cy="16" r="1.6" fill="currentColor" stroke="none"/></svg></span>
|
||||
<span>Perspective Crop…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="flip-h">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="7 11 4 7 7 3"/><polyline points="17 11 20 7 17 3"/></svg></span>
|
||||
<span>Flip horizontal</span>
|
||||
<span>Flip Canvas Horizontal</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="flip-v">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><line x1="3" y1="12" x2="21" y2="12"/><polyline points="11 7 7 4 3 7"/><polyline points="11 17 7 20 3 17"/></svg></span>
|
||||
<span>Flip vertical</span>
|
||||
<span>Flip Canvas Vertical</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Mode</div>
|
||||
<button class="dropdown-item-compact" data-image-action="mode-rgb"><span>RGB Color</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="mode-grayscale"><span>Grayscale</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="mode-cmyk" disabled title="CMYK needs a color-managed pipeline"><span>CMYK Color</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="mode-lab" disabled title="Lab needs a color-managed pipeline"><span>Lab Color</span></button>
|
||||
<div class="ge-filter-submenu-label">Bits/Channel</div>
|
||||
<button class="dropdown-item-compact" data-image-action="bits-8"><span>8 Bits/Channel</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="bits-16" disabled title="16-bit needs a 16-bit working canvas"><span>16 Bits/Channel</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="bits-32" disabled title="32-bit needs a float working canvas"><span>32 Bits/Channel</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-layer-menu-btn" title="Layer actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="m3 13 9 5 9-5"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Layer</span>
|
||||
</button>
|
||||
<div class="ge-layer-menu dropdown" id="ge-layer-menu" hidden>
|
||||
<div class="ge-menu-label">New</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="new"><span>New Layer…</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="new-fill"><span>New Fill Layer…</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="duplicate"><span>Duplicate Layer</span><span class="dropdown-shortcut">Ctrl+J</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="group"><span>Group Selected</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="rasterize"><span>Rasterize Layer</span></button>
|
||||
<div class="ge-menu-label">Modify</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="delete"><span>Delete Layer</span><span class="dropdown-shortcut">Del</span></button>
|
||||
<div class="ge-menu-label">Arrange</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="arrange-front"><span>Bring to Front</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="arrange-forward"><span>Bring Forward</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="arrange-backward"><span>Send Backward</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="arrange-back"><span>Send to Back</span></button>
|
||||
<div class="ge-menu-label">Merge</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="merge-down"><span>Merge Down</span><span class="dropdown-shortcut">Ctrl+E</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="merge-layers"><span>Merge Layers</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="merge-all"><span>Merge All</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="merge-visible"><span>Merge Visible</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="flatten"><span>Flatten Image</span></button>
|
||||
<div class="ge-menu-label">Select</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="select-all"><span>Select All Layers</span></button>
|
||||
<div class="ge-menu-label">Vector Mask</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="vector-mask-reveal" title="Add a mask that reveals the whole layer"><span>Reveal All</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="vector-mask-hide" title="Add a mask that hides the whole layer"><span>Hide All</span></button>
|
||||
<div class="ge-menu-label">Combine Shapes</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="combine-unite"><span>Unite Shapes</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="combine-subtract"><span>Subtract Shapes</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="combine-intersect"><span>Intersect Shapes</span></button>
|
||||
<button class="dropdown-item-compact" data-layer-action="combine-exclude"><span>Exclude Shapes</span></button>
|
||||
<div class="ge-menu-label">Fill</div>
|
||||
<button class="dropdown-item-compact" data-layer-action="fill"><span>Fill</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-type-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-type-menu-btn" title="Type actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Type</span>
|
||||
</button>
|
||||
<div class="ge-type-menu dropdown" id="ge-type-menu" hidden>
|
||||
<div class="ge-menu-label">Create</div>
|
||||
<button class="dropdown-item-compact" data-type-action="add"><span>Add Text Layer</span></button>
|
||||
<div class="ge-menu-label">Panels</div>
|
||||
<button class="dropdown-item-compact" data-type-action="character"><span>Character…</span></button>
|
||||
<button class="dropdown-item-compact" data-type-action="paragraph"><span>Paragraph…</span></button>
|
||||
<div class="ge-menu-label">Warp</div>
|
||||
<button class="dropdown-item-compact" data-type-action="warp"><span>Warp Text…</span></button>
|
||||
<div class="ge-menu-label">Path</div>
|
||||
<button class="dropdown-item-compact" data-type-action="text-on-path" title="Lay the active text layer along the selected path layer"><span>Text on a Path</span></button>
|
||||
<button class="dropdown-item-compact" data-type-action="text-on-circle" title="Wrap the active text layer around a circle"><span>Text on a Circle</span></button>
|
||||
<button class="dropdown-item-compact" data-type-action="text-off-path" title="Return the text layer to a straight baseline"><span>Remove from Path</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-selection-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SELECT</span>
|
||||
<span class="ge-stacked-label">Select</span>
|
||||
</button>
|
||||
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="selection-modify" aria-haspopup="true" aria-expanded="false"><span>Modify</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="selection-modify">
|
||||
<button class="dropdown-item-compact" data-selection-action="modifyBorder"><span>Border…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="modifySmooth"><span>Smooth…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="modifyExpand"><span>Expand…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="modifyContract"><span>Contract…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="modifyFeather"><span>Feather…</span></button>
|
||||
</div>
|
||||
<button class="dropdown-item-compact" data-selection-action="grow"><span>Grow</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="similar"><span>Similar</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="subject"><span>Subject</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="colorRange"><span>Color Range…</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="selectAndMask"><span>Select and Mask…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="ge-selection-save-row">
|
||||
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
|
||||
@@ -107,50 +236,281 @@ export function buildTopbar() {
|
||||
<div class="ge-filter-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">FILTER</span>
|
||||
<span class="ge-stacked-label">Filter</span>
|
||||
</button>
|
||||
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
|
||||
<div class="ge-filter-submenu-label">Retained effects</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Presets</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
|
||||
<div class="ge-filter-submenu-label">Blur</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
|
||||
<span>Zoom Blur…</span>
|
||||
</button>
|
||||
<div class="ge-menu-label">Enhance</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="dehaze"><span class="dropdown-icon" aria-hidden="true">≈</span><span>Haze Removal…</span></button>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-retained" aria-haspopup="true" aria-expanded="false"><span>Retained effects</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-retained">
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-motion-blur">
|
||||
<span class="dropdown-icon" aria-hidden="true">↔</span>
|
||||
<span>Motion Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-zoom-blur">
|
||||
<span class="dropdown-icon" aria-hidden="true">✳</span>
|
||||
<span>Zoom Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-pixelate">
|
||||
<span class="dropdown-icon" aria-hidden="true">▦</span>
|
||||
<span>Pixelate (retained)…</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-presets" aria-haspopup="true" aria-expanded="false"><span>Presets</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-presets">
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-blur" aria-haspopup="true" aria-expanded="false"><span>Blur</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-blur">
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
|
||||
<span>Zoom Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-motion"><span>Motion Blur…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="radial-blur"><span>Radial Blur…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-box"><span>Box Blur…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-surface"><span>Surface Blur…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sharpen" aria-haspopup="true" aria-expanded="false"><span>Sharpen</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-sharpen">
|
||||
<button class="dropdown-item-compact" data-filter-action="sharpen"><span>Sharpen…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sharpen-edges"><span>Sharpen Edges…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sharpen-more"><span>Sharpen More…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="unsharp-mask"><span>Unsharp Mask…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="smart-sharpen"><span>Smart Sharpen…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-noise" aria-haspopup="true" aria-expanded="false"><span>Noise</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-noise">
|
||||
<button class="dropdown-item-compact" data-filter-action="noise-add"><span>Add Noise…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="noise-despeckle"><span>Despeckle…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="reduce-noise"><span>Reduce Noise…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="dust-scratches"><span>Dust & Scratches…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="noise-median"><span>Median…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-pixelate" aria-haspopup="true" aria-expanded="false"><span>Pixelate</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-pixelate">
|
||||
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic"><span>Mosaic…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="pixelate-crystallize"><span>Crystallize…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic-tiles"><span>Mosaic Tiles…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-stylize" aria-haspopup="true" aria-expanded="false"><span>Stylize</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-stylize">
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-emboss"><span>Emboss…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-find-edges"><span>Find Edges…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-glowing-edges"><span>Glowing Edges…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-solarize"><span>Solarize…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-oil-paint"><span>Oil Paint…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-diffuse"><span>Diffuse…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-wind"><span>Wind…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="stylize-trace-contour"><span>Trace Contour…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sketch" aria-haspopup="true" aria-expanded="false"><span>Sketch</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-sketch">
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-halftone-pattern"><span>Halftone Pattern…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-reticulation"><span>Reticulation…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-note-paper"><span>Note Paper…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-charcoal"><span>Charcoal…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-chalk-charcoal"><span>Chalk & Charcoal…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-graphic-pen"><span>Graphic Pen…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-photocopy"><span>Photocopy…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="sketch-water-paper"><span>Water Paper…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-artistic" aria-haspopup="true" aria-expanded="false"><span>Artistic</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-artistic">
|
||||
<button class="dropdown-item-compact" data-filter-action="artistic-poster-edges"><span>Poster Edges…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="artistic-cutout"><span>Cutout…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-brush" aria-haspopup="true" aria-expanded="false"><span>Brush Strokes</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-brush">
|
||||
<button class="dropdown-item-compact" data-filter-action="brush-ink-outlines"><span>Ink Outlines…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="brush-crosshatch"><span>Crosshatch…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-texture" aria-haspopup="true" aria-expanded="false"><span>Texture</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-texture">
|
||||
<button class="dropdown-item-compact" data-filter-action="texture-stained-glass"><span>Stained Glass…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="texture-grain"><span>Grain…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="texture-texturizer"><span>Texturizer…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="texture-patchwork"><span>Patchwork…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="texture-craquelure"><span>Craquelure…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-distort" aria-haspopup="true" aria-expanded="false"><span>Distort</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-distort">
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-twirl"><span>Twirl…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-pinch"><span>Pinch…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-spherize"><span>Spherize…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-ripple"><span>Ripple…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-polar"><span>Polar Coordinates…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-lens-correction"><span>Lens Correction…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-ocean-ripple"><span>Ocean Ripple…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-glass"><span>Glass…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="distort-shear"><span>Shear…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-render" aria-haspopup="true" aria-expanded="false"><span>Render</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-render">
|
||||
<button class="dropdown-item-compact" data-filter-action="render-clouds"><span>Clouds…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="render-difference-clouds"><span>Difference Clouds…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="render-lens-flare"><span>Lens Flare…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="render-fibers"><span>Fibers…</span></button>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-other" aria-haspopup="true" aria-expanded="false"><span>Other</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="filter-other">
|
||||
<button class="dropdown-item-compact" data-filter-action="other-offset"><span>Offset…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="other-custom"><span>Custom…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="other-maximum"><span>Maximum…</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="other-minimum"><span>Minimum…</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<div class="ge-3d-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-3d-menu-btn" title="3D" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5"/><path d="M12 12v10"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">3D</span>
|
||||
</button>
|
||||
<div class="ge-3d-menu dropdown" id="ge-3d-menu" hidden>
|
||||
<div class="ge-menu-label">New 3D</div>
|
||||
<button class="dropdown-item-compact" data-3d-action="from-file" disabled title="3D is not available in this build"><span>New 3D Layer from File…</span></button>
|
||||
<button class="dropdown-item-compact" data-3d-action="from-layer" disabled title="3D is not available in this build"><span>New Mesh from Layer…</span></button>
|
||||
<div class="ge-menu-label">Render</div>
|
||||
<button class="dropdown-item-compact" data-3d-action="render" disabled title="3D is not available in this build"><span>Render 3D Layer</span></button>
|
||||
<div class="ge-menu-label">3D Printing</div>
|
||||
<button class="dropdown-item-compact" data-3d-action="print" disabled title="3D is not available in this build"><span>Export as 3D…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<button class="dropdown-item-compact" data-3d-action="about" disabled title="3D is not available in this build"><span>About 3D support</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">View</span>
|
||||
</button>
|
||||
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
|
||||
<div class="ge-menu-label">Overlays</div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
|
||||
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
|
||||
<div class="ge-menu-label">Snapping</div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="new-h-guide" title="Add a horizontal guide across the middle"><span class="dropdown-icon">⊢</span><span>New horizontal guide</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="new-v-guide" title="Add a vertical guide down the middle"><span class="dropdown-icon">⊣</span><span>New vertical guide</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
|
||||
<div class="ge-menu-label">Panels</div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-options"><span class="dropdown-icon ge-view-check">✓</span><span>Tool Options</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-layers"><span class="dropdown-icon ge-view-check">✓</span><span>Layers</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-swatches"><span class="dropdown-icon ge-view-check">✓</span><span>Swatches</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-gradients"><span class="dropdown-icon ge-view-check">✓</span><span>Gradients</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-patterns"><span class="dropdown-icon ge-view-check">✓</span><span>Patterns</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-properties"><span class="dropdown-icon ge-view-check">✓</span><span>Properties</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-adjustments"><span class="dropdown-icon ge-view-check">✓</span><span>Adjustments</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-histogram"><span class="dropdown-icon ge-view-check">✓</span><span>Histogram</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-info"><span class="dropdown-icon ge-view-check">✓</span><span>Info</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-channels"><span class="dropdown-icon ge-view-check">✓</span><span>Channels</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-paths"><span class="dropdown-icon ge-view-check">✓</span><span>Paths</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-character"><span class="dropdown-icon ge-view-check">✓</span><span>Character</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-navigator"><span class="dropdown-icon ge-view-check">✓</span><span>Navigator</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-history"><span class="dropdown-icon ge-view-check">✓</span><span>History</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-actions"><span class="dropdown-icon ge-view-check">✓</span><span>Actions</span></button>
|
||||
<div class="ge-menu-label">Panel layout</div>
|
||||
<button class="dropdown-item-compact" data-view-action="panel-show-all"><span class="dropdown-icon">▣</span><span>Show all</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="panel-hide-all"><span class="dropdown-icon">▢</span><span>Hide all</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="panel-reset"><span class="dropdown-icon">↺</span><span>Reset to default</span></button>
|
||||
<div class="ge-menu-label">Screen</div>
|
||||
<button class="dropdown-item-compact" data-view-action="screen-mode" title="Toggle a distraction-free, maximized screen mode"><span class="dropdown-icon">⛶</span><span>Screen Mode</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-plugins-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-plugins-menu-btn" title="Plugins" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h4a2 2 0 1 1 4 0h4v4a2 2 0 0 0 0 4v4H4v-4a2 2 0 0 0 0-4z"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Plugins</span>
|
||||
</button>
|
||||
<div class="ge-plugins-menu dropdown" id="ge-plugins-menu" hidden>
|
||||
<div class="ge-menu-label">Plugins</div>
|
||||
<button class="dropdown-item-compact" data-plugins-action="browse" disabled title="Plugins are not available in this build"><span>Browse Plugins…</span></button>
|
||||
<button class="dropdown-item-compact" data-plugins-action="manage" disabled title="Plugins are not available in this build"><span>Manage Plugins…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<button class="dropdown-item-compact" data-plugins-action="script" disabled title="Plugins are not available in this build"><span>Run Editor Script…</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-window-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-window-menu-btn" title="Window panels" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Window</span>
|
||||
</button>
|
||||
<div class="ge-window-menu dropdown" id="ge-window-menu" hidden>
|
||||
<div class="ge-menu-label">Panels</div>
|
||||
<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>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="character"><span class="dropdown-icon ge-window-check">●</span><span>Character</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="adjustments"><span class="dropdown-icon ge-window-check">●</span><span>Adjustments</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="swatches"><span class="dropdown-icon ge-window-check">●</span><span>Swatches</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="gradients"><span class="dropdown-icon ge-window-check">●</span><span>Gradients</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="channels"><span class="dropdown-icon ge-window-check">●</span><span>Channels</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="paths"><span class="dropdown-icon ge-window-check">●</span><span>Paths</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="patterns"><span class="dropdown-icon ge-window-check">●</span><span>Patterns</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="histogram"><span class="dropdown-icon ge-window-check">●</span><span>Histogram</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="info"><span class="dropdown-icon ge-window-check">●</span><span>Info</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="navigator"><span class="dropdown-icon ge-window-check">●</span><span>Navigator</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="history"><span class="dropdown-icon ge-window-check">●</span><span>History</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="actions"><span class="dropdown-icon ge-window-check">●</span><span>Actions</span></button>
|
||||
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
|
||||
<div class="ge-submenu" data-submenu-panel="window-workspace">
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic & Web</span></button>
|
||||
</div>
|
||||
<div class="ge-menu-label">Help</div>
|
||||
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="shortcuts"><span class="dropdown-icon ge-window-check">●</span><span>Keyboard Shortcuts</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-help-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-help-menu-btn" title="Help" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">Help</span>
|
||||
</button>
|
||||
<div class="ge-help-menu dropdown" id="ge-help-menu" hidden>
|
||||
<div class="ge-menu-label">Help</div>
|
||||
<button class="dropdown-item-compact" data-help-action="shortcuts"><span>Keyboard Shortcuts</span></button>
|
||||
<button class="dropdown-item-compact" data-help-action="about"><span>About Ajax Image Editor</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-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>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
|
||||
<span class="ge-stacked-label">IMPORT</span>
|
||||
@@ -161,6 +521,7 @@ export function buildTopbar() {
|
||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
@@ -179,6 +540,10 @@ export function buildTopbar() {
|
||||
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<span>Export image...</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" id="ge-export-png" title="Download the flattened image as a PNG file">
|
||||
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l4-4 5 5"/><circle cx="9" cy="8" r="1.3"/></svg></span>
|
||||
<span>Save as PNG</span>
|
||||
</button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="dropdown-section-label">Project</div>
|
||||
<button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later">
|
||||
@@ -200,7 +565,15 @@ export function buildZoomFooter() {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'ge-editor-footer';
|
||||
footer.innerHTML = `
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-footer-history" role="group" aria-label="History">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-undo" title="Undo" aria-label="Undo">↩</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-redo" title="Redo" aria-label="Redo">↪</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" 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"><path d="M12 3v18"/><path d="M5 7h14M5 17h14"/></svg></button>
|
||||
</div>
|
||||
<span class="ge-status-tool" id="ge-status-tool" aria-live="polite"></span>
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Document size"></span>
|
||||
<span class="ge-status-model" id="ge-status-model" title="AI edit model"></span>
|
||||
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
|
||||
<span class="ge-zoom-stack" aria-live="off">
|
||||
|
||||
@@ -65,6 +65,38 @@ export function transformPopupHTML() {
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field">
|
||||
<label>⇱</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-x" step="1" value="0" aria-label="Horizontal skew" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-x">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew X -1°">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew X +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field">
|
||||
<label>⇲</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-y" step="1" value="0" aria-label="Vertical skew" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-y">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew Y -1°">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew Y +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field">
|
||||
<label>⤒</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-x" step="1" value="0" aria-label="Horizontal perspective" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-x">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective X -1">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective X +1">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field">
|
||||
<label>⤓</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-y" step="1" value="0" aria-label="Vertical perspective" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-y">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective Y -1">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective Y +1">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<span class="ge-transform-quick" aria-label="Transform actions">
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user