mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-17 12:22:21 +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,201 @@
|
||||
/*
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user