oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
17import { view } from './ops.mjs'
18import { deckOp, slideOp, addBlockOp, blockLabel } from './inspector-ops.mjs'
19import { fillBlock, blockChange, setValue, showWhere } from './inspector-block.mjs'
20import { mountAssets } from './inspector-image.mjs'
21import { 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. */
24function 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 */
43export 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 */
87export 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