// fixtures/inspector.mjs — the inspector: three forms the server // rendered (templates/editor.html: Deck, Slide, Block), filled from // the deck's view for the current slide and the selected block, and // every change turned into the one op inspector-ops.mjs names for it. // // Nothing here draws a control. The bundle fills values, shows the // fields a block type or a slide layout has and hides the rest, and // listens: a change event on a field is an op, a press on a button is // a command the entry runs (the structure commands are the menu's and // the strip's too, so they live in one place). fill() runs on every // document change, whoever made it, so the person sees a peer's edit // in the controls as well as on the stage — except in the field they // are typing in, which is never overwritten under them. // // The Block section is inspector-block.mjs; this file is the deck and // slide sections, the Blocks list, and the wiring. import { view } from './ops.mjs' import { deckOp, slideOp, addBlockOp, blockLabel } from './inspector-ops.mjs' import { fillBlock, blockChange, setValue, showWhere } from './inspector-block.mjs' import { mountAssets } from './inspector-image.mjs' import { newID } from './ids.mjs' /** A custom property off the stage's section — the theme's own accent or background — as a hex, or the fallback. */ function sectionVar (stageEl, name, fallback) { const s = stageEl && stageEl.querySelector('[slides-slide]') if (!s) return fallback const v = getComputedStyle(s).getPropertyValue(name).trim() return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : fallback } /** * The Blocks list, reconciled by id rather than rebuilt: fill() runs on * EVERY document change, a colleague's keystrokes included, and a * rebuild would take the focused button out of the document under * somebody's finger — which is the keyboard's only way into a canvas * block. Same shape as strip.plan: keep what is wanted, drop what is * not, and walk the order once. * * list is the buttons' container, none the "no blocks yet" line, * blocks the slide's blocks in order, selected the selected block's * id, and label(b) the words on a button. */ export function fillBlocks ({ list, none, blocks, selected, label }) { if (none) none.hidden = blocks.length > 0 const have = new Map() for (const el of list.querySelectorAll('[data-select-block]')) have.set(el.dataset.selectBlock, el) const want = [] for (const b of blocks) { let btn = have.get(b.id) if (btn) have.delete(b.id) else { btn = document.createElement('button') btn.type = 'button' btn.setAttribute('rst-btn', 'sm') btn.dataset.selectBlock = b.id } // Written only when it differs: assigning the same text replaces // the text node, which is enough to lose a selection inside it. const words = label(b) if (btn.textContent !== words) btn.textContent = words const pressed = b.id === selected ? 'true' : 'false' if (btn.getAttribute('aria-pressed') !== pressed) btn.setAttribute('aria-pressed', pressed) want.push(btn) } for (const el of have.values()) el.remove() // insertBefore only where the order actually differs. Re-appending a // node that is already in place is still a remove and an insert, and // a focused button would be blurred by it. let at = list.firstElementChild for (const btn of want) { if (at === btn) { at = btn.nextElementSibling continue } list.insertBefore(btn, at) } } /** * Mounts the inspector. getSlideId(), getSelected(), getView() read * the editor's state; commit(ops) applies a batch; onCommand(name) * runs a button; select(id) selects a block; labels.types names each * block type. Returns {fill}. */ export function mountInspector ({ root, ydoc, stageEl, getSlideId, getSelected, getView, commit, onCommand, select, labels, upload }) { const forms = {} for (const f of root.querySelectorAll('form[data-section]')) forms[f.dataset.section] = f const field = (form, name) => form.querySelector(`[name="${name}"]`) let current = { meta: null, slide: null, block: null, deck: null } // The image block's file input and the Deck section's Unused images // list (inspector-image.mjs). Mounted here so it shares this file's // fill() and destroy() rather than needing its own lifecycle beside // them. const assets = mountAssets({ root, getView: () => current.deck || { slides: [], assets: {} }, getSlideId, getSelected, getBlock: () => current.block, commit, upload, labels: labels.upload || {} }) function fillDeck (meta) { const f = forms.deck setValue(field(f, 'rst-deck-title'), meta.title) setValue(field(f, 'rst-deck-theme'), meta.theme) setValue(field(f, 'rst-deck-accent'), meta.accent || sectionVar(stageEl, '--slides-accent', '#000000')) setValue(field(f, 'rst-deck-font-heading'), meta.fonts ? meta.fonts.heading : '') setValue(field(f, 'rst-deck-font-body'), meta.fonts ? meta.fonts.body : '') } function fillSlide (slide, ids) { const f = forms.slide for (const r of f.querySelectorAll('input[name="rst-slide-layout"]')) r.checked = r.value === slide.layout setValue(field(f, 'rst-slide-template'), slide.template) const bg = slide.background || { kind: 'theme', value: '' } setValue(field(f, 'rst-slide-background'), bg.kind === 'color' ? 'color' : 'theme') setValue(field(f, 'rst-slide-background-color'), bg.kind === 'color' && bg.value ? bg.value : sectionVar(stageEl, '--slides-bg', '#ffffff')) setValue(field(f, 'rst-slide-transition'), slide.transition) setValue(field(f, 'rst-slide-phone'), slide.phone) showWhere(f, 'data-layout', v => v === slide.layout) showWhere(f, 'data-background', v => v === bg.kind) const i = ids.indexOf(slide.id) f.querySelector('[data-command="slide-move-up"]').disabled = i <= 0 f.querySelector('[data-command="slide-move-down"]').disabled = i < 0 || i >= ids.length - 1 fillBlocksList(slide) } function fillBlocksList (slide) { fillBlocks({ list: forms.slide.querySelector('[slides-blocks-buttons]'), none: forms.slide.querySelector('[slides-blocks-none]'), blocks: slide.blocks, selected: getSelected(), label: b => blockLabel(b, labels.types[b.type] || b.type) }) } function fill () { const v = view(ydoc) const ids = v.slides.map(s => s.id) const slide = v.slides.find(s => s.id === getSlideId()) || null const block = slide ? slide.blocks.find(b => b.id === getSelected()) || null : null current = { meta: v.meta, slide, block, slides: v.slides, deck: v } fillDeck(v.meta) assets.fill() for (const el of root.querySelectorAll('[slides-when-slide]')) el.hidden = !slide if (slide) fillSlide(slide, ids) for (const el of root.querySelectorAll('[slides-when-block]')) el.hidden = !block // "Select a block to see its settings" belongs where the Block // section would be — outside the form, which is itself hidden // when there is nothing selected to fill it with. const empty = root.querySelector('[slides-inspector-empty]') if (empty) empty.hidden = !slide || !!block if (block) fillBlock({ form: forms.block, slide, block, view: getView(), labels }) } function onChange (e) { const el = e.target const form = el.closest('form[data-section]') if (!form) return const name = el.name || '' try { if (form === forms.deck && current.meta) { const f = name.replace('rst-deck-', '') if (['title', 'theme', 'accent', 'font-heading', 'font-body'].includes(f)) commit([deckOp(f, el.value, current.meta)]) } else if (form === forms.slide && current.slide) { const s = current.slide if (name === 'rst-slide-layout') commit([slideOp(s, 'layout', el.value)]) else if (name === 'rst-slide-template') commit([slideOp(s, 'template', el.value)]) else if (name === 'rst-slide-transition') commit([slideOp(s, 'transition', el.value)]) else if (name === 'rst-slide-phone') commit([slideOp(s, 'phone', el.value)]) else if (name === 'rst-slide-background-color') commit([slideOp(s, 'background-color', el.value)]) else if (name === 'rst-slide-background') { commit([el.value === 'color' ? slideOp(s, 'background-color', field(form, 'rst-slide-background-color').value) : slideOp(s, 'background', 'theme')]) } } else if (form === forms.block && current.slide && current.block) { const ops = blockChange({ el, slide: current.slide, block: current.block, view: getView() }) if (ops.length) commit(ops) } } catch (err) { console.error('inspector:', err) fill() } } function onClick (e) { const btn = e.target.closest('button') if (!btn || !root.contains(btn)) return if (btn.dataset.selectBlock) return select(btn.dataset.selectBlock) if (btn.dataset.addBlock && current.slide) { const menu = btn.closest('details') if (menu) menu.open = false const id = newID() commit([addBlockOp(current.slide, btn.dataset.addBlock, id, getSelected())]) return select(id) } if (btn.dataset.command) onCommand(btn.dataset.command) } // One signal for the three listeners: the inspector's markup is the // page's and survives a resync, so a second mount over it must be // able to say that the first is finished with it. const abort = new AbortController() const { signal } = abort root.addEventListener('change', onChange, { signal }) root.addEventListener('submit', e => e.preventDefault(), { signal }) root.addEventListener('click', onClick, { signal }) return { fill, // The uploader's progress line, so editor-entry.js can hand // createUploader an onState without reaching past this mount. onUploadState: assets.onState, destroy () { assets.destroy(); abort.abort() }, get slide () { return current.slide }, get block () { return current.block } } }