| 1 | // fixtures/inspector.mjs — the inspector: three forms the server |
| 2 | // rendered (templates/editor.html: Deck, Slide, Block), filled from |
| 3 | // the deck's view for the current slide and the selected block, and |
| 4 | // every change turned into the one op inspector-ops.mjs names for it. |
| 5 | // |
| 6 | // Nothing here draws a control. The bundle fills values, shows the |
| 7 | // fields a block type or a slide layout has and hides the rest, and |
| 8 | // listens: a change event on a field is an op, a press on a button is |
| 9 | // a command the entry runs (the structure commands are the menu's and |
| 10 | // the strip's too, so they live in one place). fill() runs on every |
| 11 | // document change, whoever made it, so the person sees a peer's edit |
| 12 | // in the controls as well as on the stage — except in the field they |
| 13 | // are typing in, which is never overwritten under them. |
| 14 | // |
| 15 | // The Block section is inspector-block.mjs; this file is the deck and |
| 16 | // slide sections, the Blocks list, and the wiring. |
| 17 | import { view } from './ops.mjs' |
| 18 | import { deckOp, slideOp, addBlockOp, blockLabel } from './inspector-ops.mjs' |
| 19 | import { fillBlock, blockChange, setValue, showWhere } from './inspector-block.mjs' |
| 20 | import { mountAssets } from './inspector-image.mjs' |
| 21 | import { newID } from './ids.mjs' |
| 22 | |
| 23 | /** A custom property off the stage's section — the theme's own accent or background — as a hex, or the fallback. */ |
| 24 | function sectionVar (stageEl, name, fallback) { |
| 25 | const s = stageEl && stageEl.querySelector('[slides-slide]') |
| 26 | if (!s) return fallback |
| 27 | const v = getComputedStyle(s).getPropertyValue(name).trim() |
| 28 | return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : fallback |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * The Blocks list, reconciled by id rather than rebuilt: fill() runs on |
| 33 | * EVERY document change, a colleague's keystrokes included, and a |
| 34 | * rebuild would take the focused button out of the document under |
| 35 | * somebody's finger — which is the keyboard's only way into a canvas |
| 36 | * block. Same shape as strip.plan: keep what is wanted, drop what is |
| 37 | * not, and walk the order once. |
| 38 | * |
| 39 | * list is the buttons' container, none the "no blocks yet" line, |
| 40 | * blocks the slide's blocks in order, selected the selected block's |
| 41 | * id, and label(b) the words on a button. |
| 42 | */ |
| 43 | export function fillBlocks ({ list, none, blocks, selected, label }) { |
| 44 | if (none) none.hidden = blocks.length > 0 |
| 45 | const have = new Map() |
| 46 | for (const el of list.querySelectorAll('[data-select-block]')) have.set(el.dataset.selectBlock, el) |
| 47 | |
| 48 | const want = [] |
| 49 | for (const b of blocks) { |
| 50 | let btn = have.get(b.id) |
| 51 | if (btn) have.delete(b.id) |
| 52 | else { |
| 53 | btn = document.createElement('button') |
| 54 | btn.type = 'button' |
| 55 | btn.setAttribute('rst-btn', 'sm') |
| 56 | btn.dataset.selectBlock = b.id |
| 57 | } |
| 58 | // Written only when it differs: assigning the same text replaces |
| 59 | // the text node, which is enough to lose a selection inside it. |
| 60 | const words = label(b) |
| 61 | if (btn.textContent !== words) btn.textContent = words |
| 62 | const pressed = b.id === selected ? 'true' : 'false' |
| 63 | if (btn.getAttribute('aria-pressed') !== pressed) btn.setAttribute('aria-pressed', pressed) |
| 64 | want.push(btn) |
| 65 | } |
| 66 | for (const el of have.values()) el.remove() |
| 67 | |
| 68 | // insertBefore only where the order actually differs. Re-appending a |
| 69 | // node that is already in place is still a remove and an insert, and |
| 70 | // a focused button would be blurred by it. |
| 71 | let at = list.firstElementChild |
| 72 | for (const btn of want) { |
| 73 | if (at === btn) { |
| 74 | at = btn.nextElementSibling |
| 75 | continue |
| 76 | } |
| 77 | list.insertBefore(btn, at) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Mounts the inspector. getSlideId(), getSelected(), getView() read |
| 83 | * the editor's state; commit(ops) applies a batch; onCommand(name) |
| 84 | * runs a button; select(id) selects a block; labels.types names each |
| 85 | * block type. Returns {fill}. |
| 86 | */ |
| 87 | export function mountInspector ({ root, ydoc, stageEl, getSlideId, getSelected, getView, commit, onCommand, select, labels, upload }) { |
| 88 | const forms = {} |
| 89 | for (const f of root.querySelectorAll('form[data-section]')) forms[f.dataset.section] = f |
| 90 | const field = (form, name) => form.querySelector(`[name="${name}"]`) |
| 91 | let current = { meta: null, slide: null, block: null, deck: null } |
| 92 | |
| 93 | // The image block's file input and the Deck section's Unused images |
| 94 | // list (inspector-image.mjs). Mounted here so it shares this file's |
| 95 | // fill() and destroy() rather than needing its own lifecycle beside |
| 96 | // them. |
| 97 | const assets = mountAssets({ |
| 98 | root, |
| 99 | getView: () => current.deck || { slides: [], assets: {} }, |
| 100 | getSlideId, |
| 101 | getSelected, |
| 102 | getBlock: () => current.block, |
| 103 | commit, |
| 104 | upload, |
| 105 | labels: labels.upload || {} |
| 106 | }) |
| 107 | |
| 108 | function fillDeck (meta) { |
| 109 | const f = forms.deck |
| 110 | setValue(field(f, 'rst-deck-title'), meta.title) |
| 111 | setValue(field(f, 'rst-deck-theme'), meta.theme) |
| 112 | setValue(field(f, 'rst-deck-accent'), meta.accent || sectionVar(stageEl, '--slides-accent', '#000000')) |
| 113 | setValue(field(f, 'rst-deck-font-heading'), meta.fonts ? meta.fonts.heading : '') |
| 114 | setValue(field(f, 'rst-deck-font-body'), meta.fonts ? meta.fonts.body : '') |
| 115 | } |
| 116 | |
| 117 | function fillSlide (slide, ids) { |
| 118 | const f = forms.slide |
| 119 | for (const r of f.querySelectorAll('input[name="rst-slide-layout"]')) r.checked = r.value === slide.layout |
| 120 | setValue(field(f, 'rst-slide-template'), slide.template) |
| 121 | const bg = slide.background || { kind: 'theme', value: '' } |
| 122 | setValue(field(f, 'rst-slide-background'), bg.kind === 'color' ? 'color' : 'theme') |
| 123 | setValue(field(f, 'rst-slide-background-color'), bg.kind === 'color' && bg.value ? bg.value : sectionVar(stageEl, '--slides-bg', '#ffffff')) |
| 124 | setValue(field(f, 'rst-slide-transition'), slide.transition) |
| 125 | setValue(field(f, 'rst-slide-phone'), slide.phone) |
| 126 | showWhere(f, 'data-layout', v => v === slide.layout) |
| 127 | showWhere(f, 'data-background', v => v === bg.kind) |
| 128 | const i = ids.indexOf(slide.id) |
| 129 | f.querySelector('[data-command="slide-move-up"]').disabled = i <= 0 |
| 130 | f.querySelector('[data-command="slide-move-down"]').disabled = i < 0 || i >= ids.length - 1 |
| 131 | fillBlocksList(slide) |
| 132 | } |
| 133 | |
| 134 | function fillBlocksList (slide) { |
| 135 | fillBlocks({ |
| 136 | list: forms.slide.querySelector('[slides-blocks-buttons]'), |
| 137 | none: forms.slide.querySelector('[slides-blocks-none]'), |
| 138 | blocks: slide.blocks, |
| 139 | selected: getSelected(), |
| 140 | label: b => blockLabel(b, labels.types[b.type] || b.type) |
| 141 | }) |
| 142 | } |
| 143 | |
| 144 | function fill () { |
| 145 | const v = view(ydoc) |
| 146 | const ids = v.slides.map(s => s.id) |
| 147 | const slide = v.slides.find(s => s.id === getSlideId()) || null |
| 148 | const block = slide ? slide.blocks.find(b => b.id === getSelected()) || null : null |
| 149 | current = { meta: v.meta, slide, block, slides: v.slides, deck: v } |
| 150 | fillDeck(v.meta) |
| 151 | assets.fill() |
| 152 | for (const el of root.querySelectorAll('[slides-when-slide]')) el.hidden = !slide |
| 153 | if (slide) fillSlide(slide, ids) |
| 154 | for (const el of root.querySelectorAll('[slides-when-block]')) el.hidden = !block |
| 155 | // "Select a block to see its settings" belongs where the Block |
| 156 | // section would be — outside the form, which is itself hidden |
| 157 | // when there is nothing selected to fill it with. |
| 158 | const empty = root.querySelector('[slides-inspector-empty]') |
| 159 | if (empty) empty.hidden = !slide || !!block |
| 160 | if (block) fillBlock({ form: forms.block, slide, block, view: getView(), labels }) |
| 161 | } |
| 162 | |
| 163 | function onChange (e) { |
| 164 | const el = e.target |
| 165 | const form = el.closest('form[data-section]') |
| 166 | if (!form) return |
| 167 | const name = el.name || '' |
| 168 | try { |
| 169 | if (form === forms.deck && current.meta) { |
| 170 | const f = name.replace('rst-deck-', '') |
| 171 | if (['title', 'theme', 'accent', 'font-heading', 'font-body'].includes(f)) commit([deckOp(f, el.value, current.meta)]) |
| 172 | } else if (form === forms.slide && current.slide) { |
| 173 | const s = current.slide |
| 174 | if (name === 'rst-slide-layout') commit([slideOp(s, 'layout', el.value)]) |
| 175 | else if (name === 'rst-slide-template') commit([slideOp(s, 'template', el.value)]) |
| 176 | else if (name === 'rst-slide-transition') commit([slideOp(s, 'transition', el.value)]) |
| 177 | else if (name === 'rst-slide-phone') commit([slideOp(s, 'phone', el.value)]) |
| 178 | else if (name === 'rst-slide-background-color') commit([slideOp(s, 'background-color', el.value)]) |
| 179 | else if (name === 'rst-slide-background') { |
| 180 | commit([el.value === 'color' |
| 181 | ? slideOp(s, 'background-color', field(form, 'rst-slide-background-color').value) |
| 182 | : slideOp(s, 'background', 'theme')]) |
| 183 | } |
| 184 | } else if (form === forms.block && current.slide && current.block) { |
| 185 | const ops = blockChange({ el, slide: current.slide, block: current.block, view: getView() }) |
| 186 | if (ops.length) commit(ops) |
| 187 | } |
| 188 | } catch (err) { |
| 189 | console.error('inspector:', err) |
| 190 | fill() |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | function onClick (e) { |
| 195 | const btn = e.target.closest('button') |
| 196 | if (!btn || !root.contains(btn)) return |
| 197 | if (btn.dataset.selectBlock) return select(btn.dataset.selectBlock) |
| 198 | if (btn.dataset.addBlock && current.slide) { |
| 199 | const menu = btn.closest('details') |
| 200 | if (menu) menu.open = false |
| 201 | const id = newID() |
| 202 | commit([addBlockOp(current.slide, btn.dataset.addBlock, id, getSelected())]) |
| 203 | return select(id) |
| 204 | } |
| 205 | if (btn.dataset.command) onCommand(btn.dataset.command) |
| 206 | } |
| 207 | |
| 208 | // One signal for the three listeners: the inspector's markup is the |
| 209 | // page's and survives a resync, so a second mount over it must be |
| 210 | // able to say that the first is finished with it. |
| 211 | const abort = new AbortController() |
| 212 | const { signal } = abort |
| 213 | root.addEventListener('change', onChange, { signal }) |
| 214 | root.addEventListener('submit', e => e.preventDefault(), { signal }) |
| 215 | root.addEventListener('click', onClick, { signal }) |
| 216 | return { |
| 217 | fill, |
| 218 | // The uploader's progress line, so editor-entry.js can hand |
| 219 | // createUploader an onState without reaching past this mount. |
| 220 | onUploadState: assets.onState, |
| 221 | destroy () { assets.destroy(); abort.abort() }, |
| 222 | get slide () { return current.slide }, |
| 223 | get block () { return current.block } |
| 224 | } |
| 225 | } |
| 226 | |