mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
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>
202 lines
7.0 KiB
JavaScript
202 lines
7.0 KiB
JavaScript
/*
|
|
* Editor control kit — shared theming for the range/number/select/checkbox
|
|
* controls scattered across the editor. It only ADDS classes and small helper
|
|
* affordances (range fill, custom steppers, scrubby labels); it never changes
|
|
* the value semantics of a control. It is strictly SCOPED to editor surfaces
|
|
* (the editor shell and its dialogs) so the rest of the app is untouched, and
|
|
* it skips controls that already have a bespoke implementation (e.g. the
|
|
* transform popup's own spinner).
|
|
*
|
|
* Styling lives in static/css/13-image-editor.css (section "Editor control kit").
|
|
*/
|
|
|
|
export const CONTROL_KIT_VERSION = '1.0';
|
|
|
|
const RANGE_SELECTOR = 'input[type="range"]';
|
|
const NUMBER_SELECTOR = 'input[type="number"]';
|
|
const CHECK_SELECTOR = 'input[type="checkbox"]';
|
|
const SELECT_SELECTOR = 'select';
|
|
|
|
// Only controls inside one of these surfaces are themed. Dialogs are appended
|
|
// to <body> outside #gallery-editor-container, so they are listed explicitly.
|
|
const EDITOR_ROOT_SELECTOR = [
|
|
'#gallery-editor-container',
|
|
'.ge-adj-popup',
|
|
'.ge-filter-overlay',
|
|
'.ge-canvas-size-overlay',
|
|
'#ge-canvas-size-overlay',
|
|
'.ge-float-window',
|
|
'.ge-transform-popup',
|
|
].join(', ');
|
|
|
|
function inEditor(el) {
|
|
return !!(el && el.closest && el.closest(EDITOR_ROOT_SELECTOR));
|
|
}
|
|
|
|
function isSkipped(el) {
|
|
if (!el || el.__geKit) return true;
|
|
if (!inEditor(el)) return true;
|
|
if (el.closest('[data-ge-nokit]')) return true;
|
|
// The transform popup renders its own themed spinner for its inputs.
|
|
if (el.closest('.ge-transform-field')) return true;
|
|
return false;
|
|
}
|
|
|
|
function numberStep(el) {
|
|
const raw = Number(el.getAttribute('step'));
|
|
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
return 1;
|
|
}
|
|
|
|
function clampToRange(el, value) {
|
|
const min = el.hasAttribute('min') ? Number(el.min) : -Infinity;
|
|
const max = el.hasAttribute('max') ? Number(el.max) : Infinity;
|
|
let v = value;
|
|
if (Number.isFinite(min)) v = Math.max(min, v);
|
|
if (Number.isFinite(max)) v = Math.min(max, v);
|
|
return v;
|
|
}
|
|
|
|
function attachScrub(handle, el) {
|
|
handle.classList.add('ge-scrub');
|
|
handle.addEventListener('pointerdown', (e) => {
|
|
if (el.disabled || el.readOnly) return;
|
|
e.preventDefault();
|
|
handle.classList.add('ge-scrubbing');
|
|
const startX = e.clientX;
|
|
const startVal = Number(el.value) || 0;
|
|
const step = numberStep(el);
|
|
let moved = false;
|
|
const move = (ev) => {
|
|
const dx = ev.clientX - startX;
|
|
if (Math.abs(dx) < 2) return;
|
|
moved = true;
|
|
const factor = ev.shiftKey ? 10 : 1;
|
|
const next = clampToRange(el, startVal + Math.round(dx) * step * factor * 0.1);
|
|
if (String(next) !== el.value) {
|
|
el.value = String(next);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
};
|
|
const up = () => {
|
|
document.removeEventListener('pointermove', move);
|
|
document.removeEventListener('pointerup', up);
|
|
handle.classList.remove('ge-scrubbing');
|
|
if (moved) el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
};
|
|
document.addEventListener('pointermove', move);
|
|
document.addEventListener('pointerup', up);
|
|
});
|
|
}
|
|
|
|
function decorateNumber(el) {
|
|
if (isSkipped(el)) return;
|
|
el.classList.add('ge-ctl-num');
|
|
el.__geKit = true;
|
|
if (el.disabled || el.readOnly) return;
|
|
// Photoshop-style scrub-on-label: drag a sibling <span> label to change the
|
|
// value; the field also keeps native arrow-key stepping. We do NOT inject a
|
|
// separate +/- stepper — most editor number fields live in display:grid
|
|
// labels, where an injected element wraps onto its own grid row and shows as
|
|
// an empty pill.
|
|
const label = el.closest('label');
|
|
if (label) {
|
|
const handle = label.querySelector(':scope > span');
|
|
if (handle && !handle.classList.contains('ge-scrub')) attachScrub(handle, el);
|
|
}
|
|
}
|
|
|
|
function updateRangeFill(el) {
|
|
const min = Number(el.min);
|
|
const max = Number(el.max);
|
|
const value = Number(el.value);
|
|
if (Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
|
const pct = ((value - min) / (max - min)) * 100;
|
|
el.style.setProperty('--ge-pct', `${Math.max(0, Math.min(100, pct))}%`);
|
|
} else {
|
|
el.style.setProperty('--ge-pct', '50%');
|
|
}
|
|
}
|
|
|
|
function decorateRange(el) {
|
|
if (isSkipped(el)) return;
|
|
el.classList.add('ge-ctl-range');
|
|
el.__geKit = true;
|
|
updateRangeFill(el);
|
|
}
|
|
|
|
function decorateSelect(el) {
|
|
if (isSkipped(el)) return;
|
|
el.classList.add('ge-ctl-select');
|
|
el.__geKit = true;
|
|
}
|
|
|
|
function decorateCheck(el) {
|
|
if (isSkipped(el)) return;
|
|
el.classList.add('ge-ctl-check');
|
|
el.__geKit = true;
|
|
}
|
|
|
|
export function initControlKit(root = document) {
|
|
if (!root || !root.querySelectorAll) return 0;
|
|
// If the root itself is a control, decorate just it; otherwise its subtree.
|
|
let count = 0;
|
|
const decorate = (el) => {
|
|
if (el.matches(RANGE_SELECTOR)) { if (!isSkipped(el)) { decorateRange(el); count += 1; } }
|
|
else if (el.matches(NUMBER_SELECTOR)) { if (!isSkipped(el)) { decorateNumber(el); count += 1; } }
|
|
else if (el.matches(SELECT_SELECTOR)) { if (!isSkipped(el)) { decorateSelect(el); count += 1; } }
|
|
else if (el.matches(CHECK_SELECTOR)) { if (!isSkipped(el)) { decorateCheck(el); count += 1; } }
|
|
};
|
|
if (root.nodeType === 1) decorate(root);
|
|
root.querySelectorAll(RANGE_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateRange(el); count += 1; } });
|
|
root.querySelectorAll(NUMBER_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateNumber(el); count += 1; } });
|
|
root.querySelectorAll(SELECT_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateSelect(el); count += 1; } });
|
|
root.querySelectorAll(CHECK_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateCheck(el); count += 1; } });
|
|
return count;
|
|
}
|
|
|
|
let installed = false;
|
|
|
|
/**
|
|
* Install once: decorates the current editor DOM, keeps range fills in sync,
|
|
* and watches for controls that appear later (dialogs, popups, dock panels).
|
|
* Only nodes that are (or contain) an editor surface are processed.
|
|
*/
|
|
export function installControlKit() {
|
|
if (installed) return;
|
|
installed = true;
|
|
const boot = () => {
|
|
document.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
|
|
};
|
|
boot();
|
|
|
|
document.addEventListener(
|
|
'input',
|
|
(e) => {
|
|
const t = e.target;
|
|
if (t && t.matches && t.matches(RANGE_SELECTOR)) updateRangeFill(t);
|
|
},
|
|
true
|
|
);
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
m.addedNodes.forEach((node) => {
|
|
if (node.nodeType !== 1) return;
|
|
const isRoot = node.matches && node.matches(EDITOR_ROOT_SELECTOR);
|
|
const containsRoot = node.querySelector && node.querySelector(EDITOR_ROOT_SELECTOR);
|
|
if (isRoot) {
|
|
initControlKit(node);
|
|
} else if (containsRoot) {
|
|
node.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
|
|
} else if (inEditor(node)) {
|
|
initControlKit(node);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
export default { CONTROL_KIT_VERSION, initControlKit, installControlKit };
|