| 1 | // fixtures/commands.mjs — the editor's structural commands, one place |
| 2 | // for the three surfaces that offer them: the Slide menu in the bar |
| 3 | // (and its shortcuts), the inspector's buttons, and the strip's item |
| 4 | // menu. Each is a batch of ops through commit() — the same vocabulary |
| 5 | // the API takes — and nothing here touches the stage: the entry's |
| 6 | // observers re-fetch what the ops changed once they are saved. |
| 7 | // |
| 8 | // ctx is read live (its fields are functions) because a resync |
| 9 | // replaces the document, the stage and the undo manager under a |
| 10 | // running editor. |
| 11 | import { slideIDs, blockIDs } from './stage.mjs' |
| 12 | import { newID } from './ids.mjs' |
| 13 | import { moveSlideOp, moveBlockOp, zOps, deckOp } from './inspector-ops.mjs' |
| 14 | import { resetPhone } from './switcher.mjs' |
| 15 | |
| 16 | /** |
| 17 | * ctx: {ydoc(), commit(ops), flush(), slideId(), selected(), slideJSON(id), |
| 18 | * blockJSON(slideId, id), undo(), redo(), present(), clearSelection(), |
| 19 | * confirmEl} — returns {run(name), strip(name, slideId)}. |
| 20 | */ |
| 21 | export function createCommands (ctx) { |
| 22 | // A new slide right after `after` (the current one by default), with |
| 23 | // a heading to type into; then the stage moves to it once it exists |
| 24 | // on the server. |
| 25 | async function newSlide (after = ctx.slideId()) { |
| 26 | const id = newID() |
| 27 | const op = { op: 'add_slide', id, layout: 'flow', template: 'title-body' } |
| 28 | if (after) op.after = after |
| 29 | ctx.commit([op, { op: 'add_block', slide: id, id: newID(), type: 'heading', region: 'heading', props: { level: 1 }, markdown: '' }]) |
| 30 | await ctx.flush() |
| 31 | location.hash = '#' + id |
| 32 | } |
| 33 | async function duplicateSlide (from = ctx.slideId()) { |
| 34 | if (!from) return |
| 35 | const id = newID() |
| 36 | ctx.commit([{ op: 'duplicate_slide', id: from, new_id: id, new_block_ids: blockIDs(ctx.ydoc(), from).map(() => newID()) }]) |
| 37 | await ctx.flush() |
| 38 | location.hash = '#' + id |
| 39 | } |
| 40 | function moveSlide (id, dir) { |
| 41 | const op = moveSlideOp(slideIDs(ctx.ydoc()), id, dir) |
| 42 | if (op) ctx.commit([op]) |
| 43 | } |
| 44 | |
| 45 | // Delete asks first, in the Slide section's own callout, and |
| 46 | // remembers which slide was asked about: the strip's menu can ask |
| 47 | // about a slide that is not yet on the stage. |
| 48 | const confirmEl = ctx.confirmEl |
| 49 | let deleteTarget = '' |
| 50 | function askDelete (id) { |
| 51 | deleteTarget = id || ctx.slideId() |
| 52 | if (!deleteTarget) return |
| 53 | if (deleteTarget !== ctx.slideId()) location.hash = '#' + deleteTarget |
| 54 | confirmEl.hidden = false |
| 55 | confirmEl.querySelector('[data-command="slide-delete"]').focus() |
| 56 | } |
| 57 | function deleteSlide () { |
| 58 | confirmEl.hidden = true |
| 59 | const id = deleteTarget |
| 60 | deleteTarget = '' |
| 61 | if (id && slideIDs(ctx.ydoc()).includes(id)) ctx.commit([{ op: 'remove_slide', id }]) |
| 62 | } |
| 63 | |
| 64 | // A block command runs on the selected block of the current slide. |
| 65 | const withBlock = fn => () => { |
| 66 | const slideId = ctx.slideId() |
| 67 | const block = ctx.blockJSON(slideId, ctx.selected()) |
| 68 | if (block) fn(slideId, block, ctx.slideJSON(slideId)) |
| 69 | } |
| 70 | const moveBlock = dir => withBlock((s, b) => { |
| 71 | const op = moveBlockOp(s, blockIDs(ctx.ydoc(), s), b.id, dir) |
| 72 | if (op) ctx.commit([op]) |
| 73 | }) |
| 74 | const layer = dir => withBlock((s, b, slide) => { |
| 75 | const ops = zOps(s, slide.blocks, b.id, dir) |
| 76 | if (ops.length) ctx.commit(ops) |
| 77 | }) |
| 78 | |
| 79 | const commands = { |
| 80 | 'new-slide': () => newSlide(), |
| 81 | 'slide-add-after': () => newSlide(), |
| 82 | 'duplicate-slide': () => duplicateSlide(), |
| 83 | 'slide-move-up': () => moveSlide(ctx.slideId(), 'up'), |
| 84 | 'slide-move-down': () => moveSlide(ctx.slideId(), 'down'), |
| 85 | 'slide-delete-ask': () => askDelete(''), |
| 86 | 'slide-delete': deleteSlide, |
| 87 | 'slide-delete-cancel': () => { confirmEl.hidden = true; deleteTarget = '' }, |
| 88 | 'accent-reset': () => ctx.commit([deckOp('accent-reset')]), |
| 89 | 'block-move-up': moveBlock('up'), |
| 90 | 'block-move-down': moveBlock('down'), |
| 91 | 'block-forward': layer('forward'), |
| 92 | 'block-back': layer('back'), |
| 93 | 'block-delete': withBlock((s, b) => { ctx.clearSelection(); ctx.commit([{ op: 'remove_block', slide: s, id: b.id }]) }), |
| 94 | 'phone-reset': withBlock((s, b) => ctx.commit([resetPhone(s, b)])), |
| 95 | undo: () => ctx.undo(), |
| 96 | redo: () => ctx.redo(), |
| 97 | present: () => ctx.present() |
| 98 | } |
| 99 | |
| 100 | return { |
| 101 | has: name => name in commands, |
| 102 | run (name) { |
| 103 | if (name in commands) commands[name]() |
| 104 | }, |
| 105 | // The strip's item menu names a slide that may not be the current one. |
| 106 | strip (name, id) { |
| 107 | if (name === 'add-after') newSlide(id) |
| 108 | else if (name === 'duplicate') duplicateSlide(id) |
| 109 | else if (name === 'move-up' || name === 'move-down') moveSlide(id, name.slice(5)) |
| 110 | else if (name === 'delete') askDelete(id) |
| 111 | }, |
| 112 | moveSlide |
| 113 | } |
| 114 | } |
| 115 | |