// fixtures/switcher.mjs — the desktop/phone switcher (spec §3.2), and // the one rule it changes: where a layout edit lands. // // Three views. Desktop is the stage as the projector sees it; phone // renders the same slide at 390 CSS pixels with ?vp=phone, the phone // layer applied; both shows the two side by side, the phone one // read-only. In the phone view an edit to a block's box, size, order // or hidden goes to its phone override map (set_phone, §2.5) rather // than to the block, and text goes to the text, because there is one // text. hidden and order live only in the phone map, so they go there // from either view. // // routeEdit is the pure rule (switcher.test.mjs). The view persists in // localStorage — a per-person convenience, never document state — and // the URL is not used for it. import { fixBox } from './canvas.mjs' export const VIEWS = ['desktop', 'phone', 'both'] export const STORAGE_KEY = 'slides.editor.view' /** The fields whose destination depends on the view. */ export const PHONE_ROUTED = ['box', 'size', 'hidden', 'order'] /** set_phone always replaces the whole map, so a change carries the rest of it along. */ export function phoneMap (block) { const p = block && block.phone ? block.phone : {} const out = {} if (typeof p.hidden === 'boolean') out.hidden = p.hidden if (Number.isInteger(p.order)) out.order = p.order if (typeof p.size === 'string' && p.size) out.size = p.size if (p.box && typeof p.box === 'object') out.box = fixBox(p.box) return out } /** * The op for editing field on block (the view's block JSON) on slide, * given the view the stage is in. value null clears the field: a * phone field goes from the map, a desktop box is left alone (a block * always has one), a desktop size goes from props. */ export function routeEdit ({ view, slide, block, field, value }) { if (!PHONE_ROUTED.includes(field)) throw new Error(`routeEdit: ${field} is not a view-dependent field`) const toPhone = view === 'phone' || field === 'hidden' || field === 'order' if (toPhone) { const phone = phoneMap(block) if (value === null || value === undefined || value === '' || value === false) delete phone[field] else phone[field] = field === 'box' ? fixBox(value) : value return { op: 'set_phone', slide, id: block.id, phone } } if (field === 'box') return { op: 'set_block', slide, id: block.id, box: fixBox(value) } return { op: 'set_block', slide, id: block.id, props: { size: value === '' ? null : value } } } /** The op that clears a block's phone layer: "Reset to desktop". */ export function resetPhone (slide, block) { return { op: 'set_phone', slide, id: block.id, phone: {} } } /** Whether a block carries any phone override. */ export function hasOverride (block) { return Object.keys(phoneMap(block)).length > 0 } /** The view to open with: what was stored, if it is one of the three. */ export function readView (storage) { try { const v = storage.getItem(STORAGE_KEY) return VIEWS.includes(v) ? v : 'desktop' } catch { return 'desktop' } } export function writeView (storage, view) { try { storage.setItem(STORAGE_KEY, view) } catch { /* private mode, quota: the choice is for this session then */ } } /** * Mounts the switcher over its fieldset of radios. onChange(view) is * called on every change and once at mount with the stored view. */ export function mountSwitcher ({ root, storage, onChange }) { const radios = Array.from(root.querySelectorAll('input[type="radio"]')) let current = readView(storage) function apply (view) { current = view for (const r of radios) r.checked = r.value === view writeView(storage, view) onChange(view) } root.addEventListener('change', e => { if (e.target.type === 'radio' && VIEWS.includes(e.target.value)) apply(e.target.value) }) apply(current) return { get view () { return current }, set: apply } }