// fixtures/inspector-image.mjs — the two pieces of the inspector that
// are about pictures (spec §2.7): the image block's file input, and the
// Deck section's list of images no slide uses any more.
//
// The upload itself is uploads.mjs's state machine; this file is the
// DOM around it. It draws nothing it was not given: every string
// arrives from the page's data attributes (the catalog), and the status
// line is text with role="status" on it in the template, so progress
// and every refusal are read out rather than shown as a colour.
//
// One rule worth stating: the file input is never the thing that
// changes the document. It hands the finished record to commit() as
// register_asset plus a set_block naming it, both in one batch, so the
// upload lands in the deck through the same op path — and one undo —
// as everything else.
import { registerAssetOps, removeAssetOp, unusedAssets } from './inspector-ops.mjs'
// The upload states that map onto a line of text, in the order they
// happen. Anything not here (idle, error) is handled at the call site.
const PROGRESS = { sign: 'uploading', put: 'uploading', register: 'checking', op: 'checking', done: 'done' }
/**
* The words for one refusal. The server's own message wins when it sent
* one — it knows why better than this does — and the client's codes
* fall back to the catalog string for each.
*/
export function uploadMessage (err, labels) {
if (!err) return ''
if (err.serverMessage) return err.serverMessage
switch (err.code) {
case 'type': return labels.type
case 'toolarge': case 'too_large': return labels.toolarge
case 'altfirst': return labels.altfirst
default: return labels.failed
}
}
/**
* Reconciles the Unused images list by asset id, the same way the
* Blocks list is reconciled and for the same reason: fill() runs on
* every document change, a colleague's included, and rebuilding would
* take a focused Remove button out of the document under somebody's
* finger.
*
* `list` is the
, `none` the "every image is on a slide" line,
* `assets` the result of unusedAssets(), `removeLabel` the button's
* word.
*/
export function fillUnused ({ list, none, assets, removeLabel }) {
if (!list) return
if (none) none.hidden = assets.length > 0
const have = new Map()
for (const li of list.querySelectorAll('[data-asset]')) have.set(li.dataset.asset, li)
const want = []
for (const a of assets) {
let li = have.get(a.id)
if (li) have.delete(a.id)
else {
li = document.createElement('li')
li.dataset.asset = a.id
const name = document.createElement('span')
name.setAttribute('slides-asset-name', '')
const btn = document.createElement('button')
btn.type = 'button'
btn.setAttribute('rst-btn', 'sm')
btn.dataset.removeAsset = a.id
li.append(name, btn)
}
// An asset uploaded without a filename is named by its address,
// shortened: it is the only thing there is to call it, and the
// whole hash tells a person nothing the first eight characters do
// not.
const words = a.name || String(a.ref || '').slice(0, 8)
const nameEl = li.querySelector('[slides-asset-name]')
if (nameEl && nameEl.textContent !== words) nameEl.textContent = words
const btn = li.querySelector('[data-remove-asset]')
if (btn) {
if (btn.textContent !== removeLabel) btn.textContent = removeLabel
// Two buttons called "Remove" in a row are one button as far as a
// screen reader's element list is concerned; the accessible name
// says which picture this one drops.
btn.setAttribute('aria-label', `${removeLabel}: ${words}`)
}
want.push(li)
}
for (const li of have.values()) li.remove()
let at = list.firstElementChild
for (const li of want) {
if (at === li) {
at = li.nextElementSibling
continue
}
list.insertBefore(li, at)
}
}
/**
* Mounts both pieces.
*
* root the inspector element (both forms are inside it)
* getView () => the deck's JSON view
* getSlideId, getSelected the current slide and block
* getBlock () => the selected block's JSON, or null
* commit (ops) => applies a batch
* upload (file, {alt, decorative}) => the record (uploads.mjs)
* labels the strings, from the page's data attributes
*
* Returns {fill, destroy}. fill() is called by the inspector's own
* fill, so the unused list follows a peer's edits too.
*/
export function mountAssets ({ root, getView, getSlideId, getSelected, getBlock, commit, upload, labels }) {
const input = root.querySelector('[slides-asset-input]')
const status = root.querySelector('[slides-asset-status]')
const list = root.querySelector('[slides-unused-list]')
const none = root.querySelector('[slides-unused-none]')
function say (state, words) {
if (!status) return
status.dataset.state = state
status.textContent = words || ''
}
async function chose (file) {
if (!file || !upload) return
// The slide and block as they are NOW: a 20 MiB upload is long
// enough for somebody to have selected another block by the time it
// lands, and it belongs to the one they were looking at when they
// picked the file.
const block = getBlock()
const slideId = getSlideId()
const props = (block && block.props) || {}
try {
await upload(file, {
alt: typeof props.alt === 'string' ? props.alt : '',
decorative: props.decorative === true,
applyOp: record => commit(registerAssetOps(record, slideId, block ? block.id : ''))
})
} catch (err) {
say('error', uploadMessage(err, labels))
return
} finally {
// Always: choosing the same file twice in a row fires no change
// event unless the value is cleared, which is exactly what
// somebody does after a failure.
if (input) input.value = ''
}
say('done', labels.done)
}
function onChange (e) {
if (e.target !== input) return
const file = input.files && input.files[0]
if (file) chose(file)
}
function onClick (e) {
const btn = e.target.closest('[data-remove-asset]')
if (!btn || !root.contains(btn)) return
commit([removeAssetOp(btn.dataset.removeAsset)])
}
function fill () {
fillUnused({ list, none, assets: unusedAssets(getView()), removeLabel: labels.remove })
}
const abort = new AbortController()
const { signal } = abort
root.addEventListener('change', onChange, { signal })
root.addEventListener('click', onClick, { signal })
return {
fill,
// onState is handed to createUploader so the status line follows
// the machine without this file knowing its steps twice.
onState (state, detail) {
if (state === 'error') return say('error', uploadMessage(detail, labels))
const key = PROGRESS[state]
if (key) say(state, labels[key])
},
destroy () { abort.abort() },
// Named so a caller (and a test) can reach the selection rule
// without going through a change event.
chose,
get selected () { return getSelected() }
}
}