| 1 | // fixtures/switcher.mjs — the desktop/phone switcher (spec §3.2), and |
| 2 | // the one rule it changes: where a layout edit lands. |
| 3 | // |
| 4 | // Three views. Desktop is the stage as the projector sees it; phone |
| 5 | // renders the same slide at 390 CSS pixels with ?vp=phone, the phone |
| 6 | // layer applied; both shows the two side by side, the phone one |
| 7 | // read-only. In the phone view an edit to a block's box, size, order |
| 8 | // or hidden goes to its phone override map (set_phone, §2.5) rather |
| 9 | // than to the block, and text goes to the text, because there is one |
| 10 | // text. hidden and order live only in the phone map, so they go there |
| 11 | // from either view. |
| 12 | // |
| 13 | // routeEdit is the pure rule (switcher.test.mjs). The view persists in |
| 14 | // localStorage — a per-person convenience, never document state — and |
| 15 | // the URL is not used for it. |
| 16 | import { fixBox } from './canvas.mjs' |
| 17 | |
| 18 | export const VIEWS = ['desktop', 'phone', 'both'] |
| 19 | export const STORAGE_KEY = 'slides.editor.view' |
| 20 | |
| 21 | /** The fields whose destination depends on the view. */ |
| 22 | export const PHONE_ROUTED = ['box', 'size', 'hidden', 'order'] |
| 23 | |
| 24 | /** set_phone always replaces the whole map, so a change carries the rest of it along. */ |
| 25 | export function phoneMap (block) { |
| 26 | const p = block && block.phone ? block.phone : {} |
| 27 | const out = {} |
| 28 | if (typeof p.hidden === 'boolean') out.hidden = p.hidden |
| 29 | if (Number.isInteger(p.order)) out.order = p.order |
| 30 | if (typeof p.size === 'string' && p.size) out.size = p.size |
| 31 | if (p.box && typeof p.box === 'object') out.box = fixBox(p.box) |
| 32 | return out |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * The op for editing field on block (the view's block JSON) on slide, |
| 37 | * given the view the stage is in. value null clears the field: a |
| 38 | * phone field goes from the map, a desktop box is left alone (a block |
| 39 | * always has one), a desktop size goes from props. |
| 40 | */ |
| 41 | export function routeEdit ({ view, slide, block, field, value }) { |
| 42 | if (!PHONE_ROUTED.includes(field)) throw new Error(`routeEdit: ${field} is not a view-dependent field`) |
| 43 | const toPhone = view === 'phone' || field === 'hidden' || field === 'order' |
| 44 | if (toPhone) { |
| 45 | const phone = phoneMap(block) |
| 46 | if (value === null || value === undefined || value === '' || value === false) delete phone[field] |
| 47 | else phone[field] = field === 'box' ? fixBox(value) : value |
| 48 | return { op: 'set_phone', slide, id: block.id, phone } |
| 49 | } |
| 50 | if (field === 'box') return { op: 'set_block', slide, id: block.id, box: fixBox(value) } |
| 51 | return { op: 'set_block', slide, id: block.id, props: { size: value === '' ? null : value } } |
| 52 | } |
| 53 | |
| 54 | /** The op that clears a block's phone layer: "Reset to desktop". */ |
| 55 | export function resetPhone (slide, block) { |
| 56 | return { op: 'set_phone', slide, id: block.id, phone: {} } |
| 57 | } |
| 58 | |
| 59 | /** Whether a block carries any phone override. */ |
| 60 | export function hasOverride (block) { |
| 61 | return Object.keys(phoneMap(block)).length > 0 |
| 62 | } |
| 63 | |
| 64 | /** The view to open with: what was stored, if it is one of the three. */ |
| 65 | export function readView (storage) { |
| 66 | try { |
| 67 | const v = storage.getItem(STORAGE_KEY) |
| 68 | return VIEWS.includes(v) ? v : 'desktop' |
| 69 | } catch { |
| 70 | return 'desktop' |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | export function writeView (storage, view) { |
| 75 | try { storage.setItem(STORAGE_KEY, view) } catch { /* private mode, quota: the choice is for this session then */ } |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Mounts the switcher over its fieldset of radios. onChange(view) is |
| 80 | * called on every change and once at mount with the stored view. |
| 81 | */ |
| 82 | export function mountSwitcher ({ root, storage, onChange }) { |
| 83 | const radios = Array.from(root.querySelectorAll('input[type="radio"]')) |
| 84 | let current = readView(storage) |
| 85 | function apply (view) { |
| 86 | current = view |
| 87 | for (const r of radios) r.checked = r.value === view |
| 88 | writeView(storage, view) |
| 89 | onChange(view) |
| 90 | } |
| 91 | root.addEventListener('change', e => { |
| 92 | if (e.target.type === 'radio' && VIEWS.includes(e.target.value)) apply(e.target.value) |
| 93 | }) |
| 94 | apply(current) |
| 95 | return { |
| 96 | get view () { return current }, |
| 97 | set: apply |
| 98 | } |
| 99 | } |
| 100 | |