feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid. - Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes. - One global default per feedback type, replacing the per-type scatter. - Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface. - XMP export prefers a real label, keeping the rating-derived mapping as a fallback. Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main. Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveFeedbackKey,
|
||||
colorShortcutHints,
|
||||
isTypingTarget,
|
||||
} from '../feedbackKeybinds';
|
||||
|
||||
/**
|
||||
* Proofing shortcuts (#1044). Two schemes share the same digit keys, so the
|
||||
* mapping is the whole feature — and the guards matter as much as the map:
|
||||
* a bare digit must not relabel a photo while someone is typing into the
|
||||
* filename search, and Cmd+1 must stay a browser tab switch.
|
||||
*/
|
||||
|
||||
const key = (k: string, init: Partial<KeyboardEventInit> = {}) =>
|
||||
new KeyboardEvent('keydown', { key: k, ...init });
|
||||
|
||||
const ALL_ON = { allowColorLabels: true, allowRatings: true } as const;
|
||||
|
||||
describe('resolveFeedbackKey — colours-only scheme', () => {
|
||||
const opts = { mode: 'colors' as const, ...ALL_ON };
|
||||
|
||||
it('maps 1/2/3 to 1st choice / 2nd choice / rejected', () => {
|
||||
expect(resolveFeedbackKey(key('1'), opts)).toEqual({ type: 'color', color: 'green' });
|
||||
expect(resolveFeedbackKey(key('2'), opts)).toEqual({ type: 'color', color: 'yellow' });
|
||||
expect(resolveFeedbackKey(key('3'), opts)).toEqual({ type: 'color', color: 'red' });
|
||||
});
|
||||
|
||||
it('leaves 4-9 unbound even when ratings are enabled', () => {
|
||||
for (const k of ['4', '5', '6', '7', '8', '9']) {
|
||||
expect(resolveFeedbackKey(key(k), opts)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the colour with 0', () => {
|
||||
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'clear' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFeedbackKey — Lightroom scheme', () => {
|
||||
const opts = { mode: 'lightroom' as const, ...ALL_ON };
|
||||
|
||||
it('maps 1-5 to star ratings', () => {
|
||||
for (const n of [1, 2, 3, 4, 5]) {
|
||||
expect(resolveFeedbackKey(key(String(n)), opts)).toEqual({ type: 'rating', value: n });
|
||||
}
|
||||
});
|
||||
|
||||
it('maps 6-9 to red / yellow / green / blue', () => {
|
||||
expect(resolveFeedbackKey(key('6'), opts)).toEqual({ type: 'color', color: 'red' });
|
||||
expect(resolveFeedbackKey(key('7'), opts)).toEqual({ type: 'color', color: 'yellow' });
|
||||
expect(resolveFeedbackKey(key('8'), opts)).toEqual({ type: 'color', color: 'green' });
|
||||
expect(resolveFeedbackKey(key('9'), opts)).toEqual({ type: 'color', color: 'blue' });
|
||||
});
|
||||
|
||||
it('clears the rating with 0, matching Lightroom', () => {
|
||||
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'rating', value: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFeedbackKey — gating', () => {
|
||||
it('ignores colour keys when colour labels are off', () => {
|
||||
expect(resolveFeedbackKey(key('1'), {
|
||||
mode: 'colors', allowColorLabels: false, allowRatings: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores star keys when ratings are off', () => {
|
||||
expect(resolveFeedbackKey(key('4'), {
|
||||
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
|
||||
})).toBeNull();
|
||||
// …but the colour keys of the same scheme still work.
|
||||
expect(resolveFeedbackKey(key('8'), {
|
||||
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
|
||||
})).toEqual({ type: 'color', color: 'green' });
|
||||
});
|
||||
|
||||
it('falls back to the colours scheme for an unknown mode', () => {
|
||||
expect(resolveFeedbackKey(key('1'), {
|
||||
mode: 'nonsense' as unknown as 'colors', ...ALL_ON,
|
||||
})).toEqual({ type: 'color', color: 'green' });
|
||||
});
|
||||
|
||||
it('never fires with a modifier held — Cmd+1 stays a tab switch', () => {
|
||||
const opts = { mode: 'colors' as const, ...ALL_ON };
|
||||
expect(resolveFeedbackKey(key('1', { metaKey: true }), opts)).toBeNull();
|
||||
expect(resolveFeedbackKey(key('1', { ctrlKey: true }), opts)).toBeNull();
|
||||
expect(resolveFeedbackKey(key('1', { altKey: true }), opts)).toBeNull();
|
||||
});
|
||||
|
||||
it('never fires while the user is typing', () => {
|
||||
const opts = { mode: 'colors' as const, ...ALL_ON };
|
||||
for (const tag of ['input', 'textarea', 'select']) {
|
||||
const element = document.createElement(tag);
|
||||
const event = key('1');
|
||||
Object.defineProperty(event, 'target', { value: element });
|
||||
expect(resolveFeedbackKey(event, opts)).toBeNull();
|
||||
}
|
||||
|
||||
const editable = document.createElement('div');
|
||||
editable.contentEditable = 'true';
|
||||
// jsdom doesn't derive isContentEditable from the attribute.
|
||||
Object.defineProperty(editable, 'isContentEditable', { value: true });
|
||||
const event = key('1');
|
||||
Object.defineProperty(event, 'target', { value: editable });
|
||||
expect(resolveFeedbackKey(event, opts)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTypingTarget', () => {
|
||||
it('is false for null and for ordinary elements', () => {
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
expect(isTypingTarget(document.createElement('div'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('colorShortcutHints', () => {
|
||||
it('reports the keys the active scheme actually binds', () => {
|
||||
expect(colorShortcutHints('colors')).toEqual({ green: '1', yellow: '2', red: '3' });
|
||||
expect(colorShortcutHints('lightroom')).toEqual({
|
||||
red: '6', yellow: '7', green: '8', blue: '9',
|
||||
});
|
||||
});
|
||||
|
||||
it('never claims a shortcut for purple — Lightroom has none either', () => {
|
||||
expect(colorShortcutHints('colors').purple).toBeUndefined();
|
||||
expect(colorShortcutHints('lightroom').purple).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { KEYBIND_SCHEMES, type ColorLabel, type KeybindMode } from '../services/feedback.service';
|
||||
|
||||
/**
|
||||
* Lightbox keyboard shortcuts for proofing (#1044).
|
||||
*
|
||||
* Shared by the gallery lightbox and the admin photo viewer — two components
|
||||
* with independent key handlers that would otherwise drift the moment either
|
||||
* one gained a shortcut.
|
||||
*/
|
||||
|
||||
export type FeedbackKeyAction =
|
||||
| { type: 'color'; color: ColorLabel }
|
||||
| { type: 'rating'; value: number }
|
||||
| { type: 'clear' };
|
||||
|
||||
interface ResolveOptions {
|
||||
mode: KeybindMode;
|
||||
allowColorLabels: boolean;
|
||||
allowRatings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the event came from somewhere a digit is real input — a search
|
||||
* box, a comment field, a contenteditable. Without this, typing "2024" into
|
||||
* the filename search would relabel the open photo.
|
||||
*/
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const element = target as HTMLElement | null;
|
||||
if (!element || typeof element.tagName !== 'string') return false;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
return element.isContentEditable === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a keydown to a proofing action, or null when the key isn't bound in the
|
||||
* active scheme (the caller's other shortcuts then get their turn).
|
||||
*
|
||||
* Modified keys are never bound: Ctrl+1 / Cmd+1 switch browser tabs, and Alt
|
||||
* combinations are OS shortcuts.
|
||||
*/
|
||||
export function resolveFeedbackKey(
|
||||
event: KeyboardEvent,
|
||||
{ mode, allowColorLabels, allowRatings }: ResolveOptions
|
||||
): FeedbackKeyAction | null {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return null;
|
||||
if (isTypingTarget(event.target)) return null;
|
||||
|
||||
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
|
||||
const key = event.key;
|
||||
|
||||
if (allowColorLabels) {
|
||||
const color = scheme.colors[key];
|
||||
if (color) return { type: 'color', color };
|
||||
}
|
||||
|
||||
if (allowRatings) {
|
||||
const rating = scheme.ratings[key];
|
||||
if (rating !== undefined) return { type: 'rating', value: rating };
|
||||
}
|
||||
|
||||
// '0' clears whichever value the scheme is primarily about: the star rating
|
||||
// in Lightroom mode (matching Lightroom itself), the colour label in
|
||||
// colour-only mode, where there are no stars to clear.
|
||||
if (key === '0') {
|
||||
if (mode === 'lightroom' && allowRatings) return { type: 'rating', value: 0 };
|
||||
if (allowColorLabels) return { type: 'clear' };
|
||||
if (allowRatings) return { type: 'rating', value: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which key sets which colour in the active scheme, for the hints rendered on
|
||||
* the swatches — e.g. { green: '1', yellow: '2', red: '3' }.
|
||||
*/
|
||||
export function colorShortcutHints(mode: KeybindMode): Partial<Record<ColorLabel, string>> {
|
||||
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
|
||||
const hints: Partial<Record<ColorLabel, string>> = {};
|
||||
for (const [key, color] of Object.entries(scheme.colors)) {
|
||||
hints[color] = key;
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
Reference in New Issue
Block a user