File size: 9,214 Bytes
4a3d0a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
/** The slide deck editor's rules: what a slide can gain or lose, how it changes to another layout, and what must not be
 * left empty. They mirror the server (backend/app/slides/content.py: LIMITS and clamp_slide), which coerces whatever
 * comes back anyway; these only keep the editor from offering what the server would undo. */
import type { EditSlide } from "../api/endpoints";

export type DeckFmt = "detailed" | "presenter";

/** The layouts a slide can have, in the order the editor lists them. */
export const LAYOUTS: { id: string; name: string }[] = [
  { id: "title", name: "Title" },
  { id: "section", name: "Section" },
  { id: "points", name: "Points" },
  { id: "two_column", name: "Two columns" },
  { id: "stat", name: "Big number" },
  { id: "quote", name: "Quote" },
  { id: "agenda", name: "Agenda" },
  { id: "closing", name: "Closing" },
];

export const MAX_SLIDES = 30;

const max = (fmt: DeckFmt) => ({ points: fmt === "presenter" ? 4 : 5, agenda: 14, takeaways: 3, items: 4 });

const clone = (s: EditSlide): EditSlide => JSON.parse(JSON.stringify(s)) as EditSlide;

/** A new slide of `layout`, with placeholder text to type over. */
export function newSlide(layout: string, fmt: DeckFmt): EditSlide {
  const blank: EditSlide = {
    layout: "section", variant: "", kicker: "", heading: "New slide", lead: "", points: [],
    left: { label: "", items: [] }, right: { label: "", items: [] }, stat: { value: "", label: "" },
    quote: { text: "", by: "" }, takeaways: [], closing: "", notes: "", sources: [],
  };
  return convert(blank, layout, fmt);
}

/** The slide changed to another layout. Its words carry over (points become a column's items, a closing's
 * takeaways...); what the new layout needs and the slide lacks is filled with placeholder text. The old layout's
 * content stays in the slide, so changing back brings it back. */
export function convert(s: EditSlide, layout: string, fmt: DeckFmt): EditSlide {
  const out = { ...clone(s), layout, variant: "" };
  const m = max(fmt);
  const words = [...s.points.map((p) => p.text), ...s.takeaways, ...s.left.items, ...s.right.items].filter(Boolean);
  const some = (n: number, fallback: string[]) => (words.length >= 2 ? words : fallback).slice(0, n);
  if (layout === "points" && out.points.length < 2) {
    out.points = some(m.points, ["First point", "Second point", "Third point"]).map((text) => ({ term: "", text }));
  } else if (layout === "agenda" && out.points.length < 2) {
    out.points = some(m.agenda, ["First topic", "Second topic", "Third topic"]).map((text) => ({ term: "", text }));
  } else if (layout === "two_column" && !(out.left.items.length && out.right.items.length)) {
    const items = some(2 * m.items, ["A point on this side", "Another point", "A point on that side", "Another point"]);
    const half = Math.ceil(items.length / 2);
    out.left = { label: out.left.label || "One side", items: items.slice(0, half).slice(0, m.items) };
    out.right = { label: out.right.label || "The other side", items: items.slice(half).slice(0, m.items) };
  } else if (layout === "stat" && !(out.stat.value && out.stat.label)) {
    out.stat = { value: out.stat.value || "42%", label: out.stat.label || words[0] || "What this number means" };
  } else if (layout === "quote" && !out.quote.text) {
    out.quote = { text: words[0] || out.lead || "A line worth quoting.", by: out.quote.by };
  } else if (layout === "closing" && !out.takeaways.length && !out.closing) {
    out.takeaways = some(m.takeaways, ["First takeaway", "Second takeaway", "Third takeaway"]);
  }
  return out;
}

export type AddAction = { id: string; label: string; apply: (s: EditSlide) => EditSlide };

/** The text boxes a slide can gain: only those its design has a place for (`slots`, from the server) and that it
 * does not have yet, within the format's limits. */
export function addActions(s: EditSlide, slots: string[], fmt: DeckFmt): AddAction[] {
  const m = max(fmt);
  const has = (slot: string) => slots.includes(slot);
  const act = (id: string, label: string, change: (c: EditSlide) => void): AddAction => ({
    id, label, apply: (from) => {
      const c = clone(from);
      change(c);
      return c;
    },
  });
  const out: AddAction[] = [];
  if (has("kicker") && !s.kicker) out.push(act("kicker", "Kicker", (c) => (c.kicker = "Kicker")));
  if (has("lead") && !s.lead) out.push(act("lead", "Subtitle", (c) => (c.lead = "A sentence that adds context.")));
  if (s.layout === "points" && has("body") && s.points.length < m.points) {
    out.push(act("point", "Bullet", (c) => c.points.push({ term: "", text: "New point" })));
  }
  if (s.layout === "agenda" && has("body") && s.points.length < m.agenda) {
    out.push(act("point", "Topic", (c) => c.points.push({ term: "", text: "New topic" })));
  }
  if (s.layout === "two_column") {
    for (const side of ["left", "right"] as const) {
      const name = side === "left" ? "Left" : "Right";
      if (!s[side].label) out.push(act(`${side}-label`, `${name} heading`, (c) => (c[side].label = "Heading")));
      if (s[side].items.length < m.items) out.push(act(`${side}-item`, `${name} item`, (c) => c[side].items.push("New item")));
    }
  }
  if (s.layout === "quote" && has("by") && !s.quote.by) out.push(act("by", "Author", (c) => (c.quote.by = "Name")));
  if (s.layout === "closing") {
    if (has("body") && s.takeaways.length < m.takeaways) out.push(act("takeaway", "Takeaway", (c) => c.takeaways.push("New takeaway")));
    if (has("closing") && !s.closing) out.push(act("closing", "Closing line", (c) => (c.closing = "One line to end on.")));
  }
  return out;
}

/** The text boxes on a slide that can be removed (a point only while the slide keeps enough of them; the heading, a
 * stat or a quote never: the layout needs them). Each is the `data-f` of the text drawn in that box. */
export function removable(s: EditSlide): string[] {
  const out: string[] = [];
  if (s.kicker) out.push("kicker");
  if (s.lead) out.push("lead");
  if (s.quote.by) out.push("quote.by");
  if (s.closing && s.takeaways.length) out.push("closing");
  const minPoints = 2;
  s.points.forEach((p, i) => {
    if (p.term) out.push(`points.${i}.term`);
    if (s.points.length > minPoints) out.push(`points.${i}.text`);
  });
  if (s.takeaways.length > 1 || s.closing) s.takeaways.forEach((_, i) => out.push(`takeaways.${i}`));
  for (const side of ["left", "right"] as const) {
    if (s[side].label) out.push(`${side}.label`);
    if (s[side].items.length > 1) s[side].items.forEach((_, i) => out.push(`${side}.items.${i}`));
  }
  return out;
}

/** The slide without the text box at `field`: a point, takeaway or column item goes; other text is emptied. */
export function removeField(s: EditSlide, field: string): EditSlide {
  const c = clone(s);
  const [a, b, d] = field.split(".");
  if (a === "points" && d === "text") c.points.splice(Number(b), 1);
  else if (a === "points" && d === "term") c.points[Number(b)].term = "";
  else if (a === "takeaways") c.takeaways.splice(Number(b), 1);
  else if ((a === "left" || a === "right") && b === "items") c[a].items.splice(Number(d), 1);
  else if ((a === "left" || a === "right") && b === "label") c[a].label = "";
  else if (a === "quote" && b === "by") c.quote.by = "";
  else if (a === "kicker" || a === "lead" || a === "closing") c[a] = "";
  return c;
}

/** The slide with the text at `field` ("points.2.text", "left.items.0", "heading") set to `text`. */
export function setText(s: EditSlide, field: string, text: string): EditSlide {
  const c = clone(s);
  const path = field.split(".");
  let at: Record<string, unknown> | unknown[] = c as unknown as Record<string, unknown>;
  for (const k of path.slice(0, -1)) at = (at as Record<string, unknown>)[k] as Record<string, unknown>;
  const last = path[path.length - 1];
  if (Array.isArray(at)) at[Number(last)] = text;
  else if (at && typeof at === "object") at[last] = text;
  return c;
}

const blank = (t: string) => !t.replace(/\s+/g, "");

/** What stops the deck from being saved as it is ("Slide 3: the heading can't be empty."), or null. */
export function problem(slides: EditSlide[]): string | null {
  for (const [i, s] of slides.entries()) {
    const at = (what: string) => `Slide ${i + 1}: ${what} can't be empty.`;
    if (blank(s.heading) && s.layout !== "quote" && s.layout !== "closing") return at("the heading");
    if (s.layout === "points" || s.layout === "agenda") {
      const n = s.points.findIndex((p) => blank(p.text));
      if (n >= 0) return at(`${s.layout === "agenda" ? "topic" : "point"} ${n + 1}`);
    }
    if (s.layout === "two_column") {
      for (const side of ["left", "right"] as const) {
        const n = s[side].items.findIndex(blank);
        if (n >= 0) return at(`the ${side} column's item ${n + 1}`);
      }
    }
    if (s.layout === "stat" && (blank(s.stat.value) || blank(s.stat.label))) return at(blank(s.stat.value) ? "the number" : "the number's label");
    if (s.layout === "quote" && blank(s.quote.text)) return at("the quote");
    if (s.layout === "closing") {
      const n = s.takeaways.findIndex(blank);
      if (n >= 0) return at(`takeaway ${n + 1}`);
    }
  }
  return null;
}