squiffy-runtime 6.0.0-alpha.15 → 6.0.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/animation.d.ts +11 -0
  2. package/dist/events.d.ts +8 -3
  3. package/dist/import.d.ts +4 -0
  4. package/dist/linkHandler.d.ts +8 -0
  5. package/dist/pluginManager.d.ts +23 -0
  6. package/dist/squiffy.runtime.d.ts +2 -2
  7. package/dist/squiffy.runtime.global.js +126 -0
  8. package/dist/squiffy.runtime.global.js.map +1 -0
  9. package/dist/squiffy.runtime.js +8785 -487
  10. package/dist/squiffy.runtime.js.map +1 -0
  11. package/dist/state.d.ts +19 -0
  12. package/dist/textProcessor.d.ts +8 -13
  13. package/dist/types.d.ts +5 -2
  14. package/dist/types.plugins.d.ts +27 -0
  15. package/dist/updater.d.ts +2 -0
  16. package/dist/utils.d.ts +1 -2
  17. package/package.json +12 -5
  18. package/src/__snapshots__/squiffy.runtime.test.ts.snap +53 -19
  19. package/src/animation.ts +68 -0
  20. package/src/events.ts +9 -10
  21. package/src/import.ts +5 -0
  22. package/src/linkHandler.ts +18 -0
  23. package/src/pluginManager.ts +74 -0
  24. package/src/plugins/animate.ts +97 -0
  25. package/src/plugins/index.ts +13 -0
  26. package/src/plugins/live.ts +83 -0
  27. package/src/plugins/random.ts +22 -0
  28. package/src/plugins/replaceLabel.ts +22 -0
  29. package/src/plugins/rotateSequence.ts +61 -0
  30. package/src/squiffy.runtime.test.ts +306 -134
  31. package/src/squiffy.runtime.ts +460 -332
  32. package/src/state.ts +106 -0
  33. package/src/textProcessor.ts +61 -164
  34. package/src/types.plugins.ts +41 -0
  35. package/src/types.ts +5 -2
  36. package/src/updater.ts +77 -0
  37. package/src/utils.ts +15 -12
  38. package/vite.config.ts +36 -0
  39. package/vitest.config.ts +9 -0
  40. package/vitest.setup.ts +16 -0
  41. package/dist/events.js +0 -35
  42. package/dist/squiffy.runtime.test.js +0 -394
  43. package/dist/textProcessor.js +0 -166
  44. package/dist/types.js +0 -1
  45. package/dist/utils.js +0 -14
@@ -1,158 +1,129 @@
1
- import { SquiffyApi, SquiffyInitOptions, SquiffySettings, Story, Section } from './types.js';
2
- import { startsWith, rotate } from "./utils.js";
3
- import { TextProcessor } from './textProcessor.js';
4
- import { Emitter, SquiffyEventMap } from './events.js';
1
+ import { SquiffyApi, SquiffyInitOptions, Story, Section, Passage } from "./types.js";
2
+ import { TextProcessor } from "./textProcessor.js";
3
+ import { Emitter, SquiffyEventMap } from "./events.js";
4
+ import { State } from "./state.js";
5
+ import { updateStory } from "./updater.js";
6
+ import { PluginManager } from "./pluginManager.js";
7
+ import { Plugins } from "./plugins/index.js";
8
+ import { LinkHandler } from "./linkHandler.js";
9
+ import { Animation } from "./animation.js";
10
+ import { imports } from "./import.js";
5
11
 
6
- export type { SquiffyApi } from "./types.js"
12
+ export type { SquiffyApi } from "./types.js";
7
13
 
8
- export const init = (options: SquiffyInitOptions): SquiffyApi => {
14
+ export const init = async (options: SquiffyInitOptions): Promise<SquiffyApi> => {
9
15
  let story: Story;
10
16
  let currentSection: Section;
11
17
  let currentSectionElement: HTMLElement;
18
+ let currentPassageElement: HTMLElement;
12
19
  let currentBlockOutputElement: HTMLElement;
13
20
  let scrollPosition = 0;
14
- let outputElement: HTMLElement;
15
- let settings: SquiffySettings;
16
- let storageFallback: Record<string, string> = {};
17
- let textProcessor: TextProcessor;
18
21
  const emitter = new Emitter<SquiffyEventMap>();
22
+ const transitions: (() => Promise<void>)[] = [];
23
+ let runningTransitions = false;
19
24
 
20
- function set(attribute: string, value: any) {
21
- if (typeof value === 'undefined') value = true;
22
- if (settings.persist && window.localStorage) {
23
- localStorage[story.id + '-' + attribute] = JSON.stringify(value);
24
- }
25
- else {
26
- storageFallback[attribute] = JSON.stringify(value);
27
- }
28
- settings.onSet(attribute, value);
29
- }
30
-
31
- function get(attribute: string): any {
32
- let result;
33
- if (settings.persist && window.localStorage) {
34
- result = localStorage[story.id + '-' + attribute];
35
- }
36
- else {
37
- result = storageFallback[attribute];
38
- }
39
- if (!result) return null;
40
- return JSON.parse(result);
41
- }
42
-
43
- function handleLink(link: HTMLElement): boolean {
44
- const outputSection = link.closest('.squiffy-output-section');
25
+ async function handleLink(link: HTMLElement): Promise<boolean> {
26
+ if (runningTransitions) return false;
27
+ const outputSection = link.closest(".squiffy-output-section");
45
28
  if (outputSection !== currentSectionElement) return false;
46
29
 
47
- if (link.classList.contains('disabled')) return false;
30
+ if (link.classList.contains("disabled")) return false;
31
+
32
+ const passage = link.getAttribute("data-passage");
33
+ const section = link.getAttribute("data-section");
48
34
 
49
- let passage = link.getAttribute('data-passage');
50
- let section = link.getAttribute('data-section');
51
- const rotateAttr = link.getAttribute('data-rotate');
52
- const sequenceAttr = link.getAttribute('data-sequence');
53
- const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
54
- if (passage) {
35
+ if (passage !== null) {
55
36
  disableLink(link);
56
- set('_turncount', get('_turncount') + 1);
57
- passage = processLink(passage);
37
+ set("_turncount", get("_turncount") + 1);
38
+ await processLink(link);
58
39
  if (passage) {
59
- newBlockOutputElement();
60
- showPassage(passage);
40
+ currentBlockOutputElement = null;
41
+ await showPassage(passage);
61
42
  }
62
- const turnPassage = '@' + get('_turncount');
43
+ const turnPassage = "@" + get("_turncount");
63
44
  if (currentSection.passages) {
64
45
  if (turnPassage in currentSection.passages) {
65
- showPassage(turnPassage);
46
+ await showPassage(turnPassage);
66
47
  }
67
- if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
68
- showPassage('@last');
48
+ if ("@last" in currentSection.passages && get("_turncount") >= (currentSection.passageCount || 0)) {
49
+ await showPassage("@last");
69
50
  }
70
51
  }
71
52
 
72
- emitter.emit('linkClick', { linkType: 'passage' });
53
+ emitter.emit("linkClick", { linkType: "passage" });
73
54
  return true;
74
55
  }
75
56
 
76
- if (section) {
77
- section = processLink(section);
57
+ if (section !== null) {
58
+ await processLink(link);
78
59
  if (section) {
79
- go(section);
60
+ await go(section);
80
61
  }
81
62
 
82
- emitter.emit('linkClick', { linkType: 'section' });
63
+ emitter.emit("linkClick", { linkType: "section" });
83
64
  return true;
84
65
  }
66
+
67
+ const [handled, type, result] = linkHandler.handleLink(link);
85
68
 
86
- if (rotateOrSequenceAttr) {
87
- const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
88
- link.innerHTML = result[0]!.replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
89
- const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
90
- link.setAttribute(dataAttribute, result[1] || '');
91
- if (!result[1]) {
69
+ if (handled) {
70
+ if (result?.disableLink) {
92
71
  disableLink(link);
93
72
  }
94
- const attribute = link.getAttribute('data-attribute');
95
- if (attribute) {
96
- set(attribute, result[0]);
97
- }
73
+
98
74
  save();
99
75
 
100
- emitter.emit('linkClick', { linkType: rotateAttr ? 'rotate' : 'sequence' });
76
+ emitter.emit("linkClick", { linkType: type });
101
77
  return true;
102
78
  }
103
79
 
104
80
  return false;
105
81
  }
106
82
 
107
- function handleClick(event: Event) {
83
+ async function handleClick(event: Event) {
108
84
  const target = event.target as HTMLElement;
109
- if (target.classList.contains('squiffy-link')) {
110
- handleLink(target);
85
+ if (target.classList.contains("squiffy-link")) {
86
+ await handleLink(target);
111
87
  }
112
88
  }
113
89
 
114
90
  function disableLink(link: Element) {
115
- link.classList.add('disabled');
116
- link.setAttribute('tabindex', '-1');
91
+ link.classList.add("disabled");
92
+ link.setAttribute("tabindex", "-1");
93
+ }
94
+
95
+ function enableLink(link: Element) {
96
+ link.classList.remove("disabled");
97
+ link.removeAttribute("tabindex");
117
98
  }
118
99
 
119
- function begin() {
100
+ async function begin() {
120
101
  if (!load()) {
121
- go(story.start);
102
+ await go(story.start);
122
103
  }
123
104
  }
124
105
 
125
- function processLink(link: string): string | null {
126
- const sections = link.split(',');
127
- let first = true;
128
- let target: string | null = null;
129
- sections.forEach(function (section) {
130
- section = section.trim();
131
- if (startsWith(section, '@replace ')) {
132
- replaceLabel(section.substring(9));
133
- }
134
- else {
135
- if (first) {
136
- target = section;
137
- }
138
- else {
139
- setAttribute(section);
140
- }
106
+ async function processLink(link: HTMLElement) {
107
+ animation.runLinkAnimation(link);
108
+ await runTransitions();
109
+ const settersJson = link.getAttribute("data-set");
110
+ if (settersJson) {
111
+ const setters = JSON.parse(settersJson) as string[];
112
+ for (const attribute of setters) {
113
+ setAttribute(attribute);
141
114
  }
142
- first = false;
143
- });
144
- return target;
115
+ }
145
116
  }
146
117
 
147
118
  function setAttribute(expr: string) {
148
- expr = expr.replace(/^(\w*\s*):=(.*)$/, (_, name, value) => (name + "=" + ui.processText(value)));
119
+ expr = expr.replace(/^(\w*\s*):=(.*)$/, (_, name, value) => (name + "=" + ui.processText(value, true)));
149
120
  const setRegex = /^([\w]*)\s*=\s*(.*)$/;
150
121
  const setMatch = setRegex.exec(expr);
151
122
  if (setMatch) {
152
123
  const lhs = setMatch[1];
153
124
  let rhs = setMatch[2];
154
125
  if (isNaN(rhs as any)) {
155
- if (startsWith(rhs, "@")) rhs = get(rhs.substring(1));
126
+ if (rhs.startsWith("@")) rhs = get(rhs.substring(1));
156
127
  set(lhs, rhs);
157
128
  }
158
129
  else {
@@ -160,33 +131,33 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
160
131
  }
161
132
  }
162
133
  else {
163
- const incDecRegex = /^([\w]*)\s*([\+\-\*\/])=\s*(.*)$/;
134
+ const incDecRegex = /^([\w]*)\s*([+\-*/])=\s*(.*)$/;
164
135
  const incDecMatch = incDecRegex.exec(expr);
165
136
  if (incDecMatch) {
166
137
  const lhs = incDecMatch[1];
167
138
  const op = incDecMatch[2];
168
139
  let rhs = incDecMatch[3];
169
- if (startsWith(rhs, "@")) rhs = get(rhs.substring(1));
140
+ if (rhs.startsWith("@")) rhs = get(rhs.substring(1));
170
141
  const rhsNumeric = parseFloat(rhs);
171
142
  let value = get(lhs);
172
143
  if (value === null) value = 0;
173
- if (op == '+') {
144
+ if (op == "+") {
174
145
  value += rhsNumeric;
175
146
  }
176
- if (op == '-') {
147
+ if (op == "-") {
177
148
  value -= rhsNumeric;
178
149
  }
179
- if (op == '*') {
150
+ if (op == "*") {
180
151
  value *= rhsNumeric;
181
152
  }
182
- if (op == '/') {
153
+ if (op == "/") {
183
154
  value /= rhsNumeric;
184
155
  }
185
156
  set(lhs, value);
186
157
  }
187
158
  else {
188
159
  let value = true;
189
- if (startsWith(expr, 'not ')) {
160
+ if (expr.startsWith("not ")) {
190
161
  expr = expr.substring(4);
191
162
  value = false;
192
163
  }
@@ -194,135 +165,143 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
194
165
  }
195
166
  }
196
167
  }
197
-
198
- function replaceLabel(expr: string) {
199
- const regex = /^([\w]*)\s*=\s*(.*)$/;
200
- const match = regex.exec(expr);
201
- if (!match) return;
202
- const label = match[1];
203
- let text = match[2];
204
- if (currentSection.passages && text in currentSection.passages) {
205
- text = currentSection.passages[text].text || '';
206
- }
207
- else if (text in story.sections) {
208
- text = story.sections[text].text || '';
209
- }
210
- const stripParags = /^<p>(.*)<\/p>$/;
211
- const stripParagsMatch = stripParags.exec(text);
212
- if (stripParagsMatch) {
213
- text = stripParagsMatch[1];
214
- }
215
-
216
- const labelElement = outputElement.querySelector('.squiffy-label-' + label);
217
- if (!labelElement) return;
218
-
219
- labelElement.addEventListener('transitionend', function () {
220
- labelElement.innerHTML = ui.processText(text);
221
-
222
- labelElement.addEventListener('transitionend', function () {
223
- save();
224
- }, { once: true });
225
-
226
- labelElement.classList.remove('fade-out');
227
- labelElement.classList.add('fade-in');
228
- }, { once: true });
229
-
230
- labelElement.classList.add('fade-out');
231
- }
232
-
233
- function go(sectionName: string) {
234
- set('_transition', null);
168
+
169
+ async function go(sectionName: string) {
170
+ const oldCanGoBack = canGoBack();
235
171
  newSection(sectionName);
236
172
  currentSection = story.sections[sectionName];
237
173
  if (!currentSection) return;
238
- set('_section', sectionName);
239
- setSeen(sectionName);
240
- const master = story.sections[''];
174
+ set("_section", sectionName);
175
+ state.setSeen(sectionName);
176
+ const master = story.sections[""];
177
+ if (master?.clear || currentSection.clear) {
178
+ clearScreen();
179
+ }
241
180
  if (master) {
242
- run(master);
243
- ui.write(master.text || '', "[[]]");
181
+ await run(master, "[[]]");
244
182
  }
245
- run(currentSection);
183
+ await run(currentSection, `[[${sectionName}]]`);
246
184
  // The JS might have changed which section we're in
247
- if (get('_section') == sectionName) {
248
- set('_turncount', 0);
249
- ui.write(currentSection.text || '', `[[${sectionName}]]`);
185
+ if (get("_section") == sectionName) {
186
+ set("_turncount", 0);
187
+ writeUndoLog();
250
188
  save();
251
189
  }
190
+ const newCanGoBack = canGoBack();
191
+ if (newCanGoBack != oldCanGoBack) {
192
+ emitter.emit("canGoBackChanged", { canGoBack: newCanGoBack });
193
+ }
194
+ }
195
+
196
+ function runJs(index: number, extra: any = null) {
197
+ const squiffy = {
198
+ get: get,
199
+ set: set,
200
+ ui: {
201
+ transition: addTransition,
202
+ write: ui.write,
203
+ scrollToEnd: ui.scrollToEnd,
204
+ },
205
+ story: {
206
+ go: go,
207
+ },
208
+ element: outputElementContainer,
209
+ import: imports,
210
+ ...extra
211
+ };
212
+ story.js[index](squiffy, get, set);
252
213
  }
253
214
 
254
- function run(section: Section) {
255
- if (section.clear) {
256
- ui.clearScreen();
257
- }
215
+ async function run(section: Section, source: string) {
258
216
  if (section.attributes) {
259
- processAttributes(section.attributes.map(line => line.replace(/^random\s*:\s*(\w+)\s*=\s*(.+)/i, (line, attr, options) => (options = options.split("|")) ? attr + " = " + options[Math.floor(Math.random() * options.length)] : line)));
217
+ await processAttributes(section.attributes);
260
218
  }
261
219
  if (section.jsIndex !== undefined) {
262
- const squiffy = {
263
- get: get,
264
- set: set,
265
- ui: {
266
- transition: ui.transition,
267
- },
268
- story: {
269
- go: go,
270
- },
271
- };
272
- story.js[section.jsIndex](squiffy, get, set);
220
+ runJs(section.jsIndex);
273
221
  }
222
+
223
+ ui.write(section.text || "", source);
224
+
225
+ await runTransitions();
226
+ }
227
+
228
+ async function runTransitions() {
229
+ runningTransitions = true;
230
+ currentSectionElement.classList.add("links-disabled");
231
+ for (const transition of transitions) {
232
+ await transition();
233
+ }
234
+ transitions.length = 0;
235
+ runningTransitions = false;
236
+ currentSectionElement.classList.remove("links-disabled");
274
237
  }
275
238
 
276
- function showPassage(passageName: string) {
239
+ async function showPassage(passageName: string) {
240
+ const oldCanGoBack = canGoBack();
277
241
  let passage = currentSection.passages && currentSection.passages[passageName];
278
- const masterSection = story.sections[''];
242
+ const masterSection = story.sections[""];
279
243
  if (!passage && masterSection && masterSection.passages) passage = masterSection.passages[passageName];
280
244
  if (!passage) {
281
245
  throw `No passage named ${passageName} in the current section or master section`;
282
246
  }
283
- setSeen(passageName);
247
+ state.setSeen(passageName);
248
+
249
+ const passages: Passage[] = [];
250
+ const runFns: (() => Promise<void>)[] = [];
251
+
284
252
  if (masterSection && masterSection.passages) {
285
- const masterPassage = masterSection.passages[''];
253
+ const masterPassage = masterSection.passages[""];
286
254
  if (masterPassage) {
287
- run(masterPassage);
288
- ui.write(masterPassage.text || '', `[[]][]`);
255
+ passages.push(masterPassage);
256
+ runFns.push(() => run(masterPassage, "[[]][]"));
289
257
  }
290
258
  }
291
- const master = currentSection.passages && currentSection.passages[''];
259
+
260
+ const master = currentSection.passages && currentSection.passages[""];
292
261
  if (master) {
293
- run(master);
294
- ui.write(master.text || '', `[[${get("_section")}]][]`);
262
+ passages.push(master);
263
+ runFns.push(() => run(master, `[[${get("_section")}]][]`));
264
+ }
265
+
266
+ passages.push(passage);
267
+ runFns.push(() => run(passage, `[[${get("_section")}]][${passageName}]`));
268
+
269
+ if (passages.some(p => p.clear)) {
270
+ clearScreen();
271
+ createSectionElement();
272
+ }
273
+
274
+ currentPassageElement = document.createElement("div");
275
+ currentPassageElement.classList.add("squiffy-output-passage");
276
+ currentPassageElement.setAttribute("data-passage", `${passageName}`);
277
+
278
+ currentSectionElement.appendChild(currentPassageElement);
279
+ currentBlockOutputElement = null;
280
+
281
+ for (const fn of runFns) {
282
+ await fn();
295
283
  }
296
- run(passage);
297
- ui.write(passage.text || '', `[[${get("_section")}]][${passageName}]`);
284
+
285
+ writeUndoLog();
298
286
  save();
287
+ const newCanGoBack = canGoBack();
288
+ if (newCanGoBack != oldCanGoBack) {
289
+ emitter.emit("canGoBackChanged", { canGoBack: newCanGoBack });
290
+ }
299
291
  }
300
292
 
301
- function processAttributes(attributes: string[]) {
302
- attributes.forEach(function (attribute) {
303
- if (startsWith(attribute, '@replace ')) {
304
- replaceLabel(attribute.substring(9));
305
- }
306
- else {
307
- setAttribute(attribute);
308
- }
309
- });
293
+ async function processAttributes(attributes: string[]) {
294
+ for (const attribute of attributes) {
295
+ setAttribute(attribute);
296
+ }
310
297
  }
311
298
 
312
299
  function restart() {
313
- if (settings.persist && window.localStorage && story.id) {
314
- const keys = Object.keys(localStorage);
315
- for (const key of keys) {
316
- if (startsWith(key, story.id)) {
317
- localStorage.removeItem(key);
318
- }
319
- }
320
- }
321
- else {
322
- storageFallback = {};
323
- }
324
- if (settings.scroll === 'element') {
325
- outputElement.innerHTML = '';
300
+ state.reset();
301
+ // TODO: This feels like the wrong way of triggering location.reload()
302
+ // - should be a separate setting to the scroll setting.
303
+ if (settings.scroll === "element" || settings.scroll === "none") {
304
+ outputElement.innerHTML = "";
326
305
  begin();
327
306
  }
328
307
  else {
@@ -331,106 +310,162 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
331
310
  }
332
311
 
333
312
  function save() {
334
- set('_output', outputElement.innerHTML);
313
+ // TODO: Queue up all attribute changes and save them only when this is called
314
+ set("_output", outputElement.innerHTML);
335
315
  }
336
316
 
337
317
  function load() {
338
- const output = get('_output');
339
- if (!output) return false;
318
+ const runUiJs = () => {
319
+ if (story.uiJsIndex !== undefined) {
320
+ runJs(story.uiJsIndex, {
321
+ registerAnimation: animation.registerAnimation.bind(animation),
322
+ });
323
+ }
324
+ };
325
+
326
+ state.load();
327
+ const output = get("_output");
328
+ if (!output) {
329
+ runUiJs();
330
+ return false;
331
+ }
332
+
340
333
  outputElement.innerHTML = output;
341
334
 
342
- currentSectionElement = outputElement.querySelector('.squiffy-output-section:last-child');
343
- currentBlockOutputElement = outputElement.querySelector('.squiffy-output-block:last-child');
335
+ setCurrentSectionElement();
336
+ setCurrentPassageElement();
337
+ currentBlockOutputElement = outputElement.querySelector(".squiffy-output-block:last-child");
344
338
 
345
- currentSection = story.sections[get('_section')];
346
- const transition = get('_transition');
347
- if (transition) {
348
- eval('(' + transition + ')()');
349
- }
339
+ currentSection = story.sections[get("_section")];
340
+ runUiJs();
341
+ pluginManager.onLoad();
350
342
  return true;
351
343
  }
352
-
353
- function setSeen(sectionName: string) {
354
- let seenSections = get('_seen_sections');
355
- if (!seenSections) seenSections = [];
356
- if (seenSections.indexOf(sectionName) == -1) {
357
- seenSections.push(sectionName);
358
- set('_seen_sections', seenSections);
359
- }
360
- }
361
-
362
- function seen(sectionName: string) {
363
- const seenSections = get('_seen_sections');
364
- if (!seenSections) return false;
365
- return (seenSections.indexOf(sectionName) > -1);
366
- }
367
344
 
368
345
  function newBlockOutputElement() {
369
- currentBlockOutputElement = document.createElement('div');
370
- currentBlockOutputElement.classList.add('squiffy-output-block');
371
- currentSectionElement?.appendChild(currentBlockOutputElement);
346
+ currentBlockOutputElement = document.createElement("div");
347
+ currentBlockOutputElement.classList.add("squiffy-output-block");
348
+ (currentPassageElement || currentSectionElement)?.appendChild(currentBlockOutputElement);
372
349
  }
373
350
 
374
- function newSection(sectionName: string | null) {
351
+ function newSection(sectionName?: string) {
375
352
  if (currentSectionElement) {
376
- currentSectionElement.querySelectorAll('input').forEach(el => {
377
- const attribute = el.getAttribute('data-attribute') || el.id;
353
+ currentSectionElement.querySelectorAll("input").forEach(el => {
354
+ const attribute = el.getAttribute("data-attribute") || el.id;
378
355
  if (attribute) set(attribute, el.value);
379
356
  el.disabled = true;
380
357
  });
381
358
 
382
359
  currentSectionElement.querySelectorAll("[contenteditable]").forEach(el => {
383
- const attribute = el.getAttribute('data-attribute') || el.id;
360
+ const attribute = el.getAttribute("data-attribute") || el.id;
384
361
  if (attribute) set(attribute, el.innerHTML);
385
- (el as HTMLElement).contentEditable = 'false';
362
+ (el as HTMLElement).contentEditable = "false";
386
363
  });
387
364
 
388
- currentSectionElement.querySelectorAll('textarea').forEach(el => {
389
- const attribute = el.getAttribute('data-attribute') || el.id;
365
+ currentSectionElement.querySelectorAll("textarea").forEach(el => {
366
+ const attribute = el.getAttribute("data-attribute") || el.id;
390
367
  if (attribute) set(attribute, el.value);
391
368
  el.disabled = true;
392
369
  });
393
370
  }
394
-
395
- const sectionCount = get('_section-count') + 1;
396
- set('_section-count', sectionCount);
397
- const id = 'squiffy-section-' + sectionCount;
398
-
399
- currentSectionElement = document.createElement('div');
400
- currentSectionElement.classList.add('squiffy-output-section');
401
- currentSectionElement.id = id;
402
- if (sectionName) {
403
- currentSectionElement.setAttribute('data-section', `${sectionName}`);
371
+
372
+ currentPassageElement = null;
373
+ createSectionElement(sectionName);
374
+ newBlockOutputElement();
375
+ }
376
+
377
+ function createSectionElement(sectionName?: string) {
378
+ currentSectionElement = document.createElement("div");
379
+ currentSectionElement.classList.add("squiffy-output-section");
380
+ currentSectionElement.setAttribute("data-section", sectionName ?? get("_section"));
381
+ if (!sectionName) {
382
+ currentSectionElement.setAttribute("data-clear", "true");
404
383
  }
405
384
  outputElement.appendChild(currentSectionElement);
406
- newBlockOutputElement();
385
+ }
386
+
387
+ function getClearStack() {
388
+ return outputElement.querySelector<HTMLElement>(".squiffy-clear-stack");
389
+ }
390
+
391
+ function clearScreen() {
392
+ // Callers should call createSectionElement() after calling this function, so there's a place for new
393
+ // output to go. We don't call this automatically within this function, in case we're just about to create
394
+ // a new section anyway.
395
+ let clearStack = getClearStack();
396
+ if (!clearStack) {
397
+ clearStack = document.createElement("div");
398
+ clearStack.classList.add("squiffy-clear-stack");
399
+ clearStack.style.display = "none";
400
+ outputElement.prepend(clearStack);
401
+ }
402
+
403
+ const clearStackItem = document.createElement("div");
404
+ clearStack.appendChild(clearStackItem);
405
+
406
+ // Move everything in the outputElement (except the clearStack itself) into the new clearStackItem
407
+ for (const child of [...outputElement.children]) {
408
+ if (child !== clearStack) {
409
+ clearStackItem.appendChild(child);
410
+ }
411
+ }
412
+
413
+ // NOTE: If we offer an option to disable the "back" feature, all of the above can be replaced with:
414
+ // outputElement.innerHTML = '';
415
+ }
416
+
417
+ function unClearScreen() {
418
+ const clearStack = getClearStack();
419
+ for (const child of [...outputElement.children]) {
420
+ if (child !== clearStack) {
421
+ child.remove();
422
+ }
423
+ }
424
+
425
+ const clearStackItem = clearStack.children[clearStack.children.length - 1];
426
+ for (const child of [...clearStackItem.children]) {
427
+ outputElement.appendChild(child);
428
+ }
429
+ clearStackItem.remove();
407
430
  }
408
431
 
409
432
  const ui = {
410
433
  write: (text: string, source: string) => {
411
- if (!currentBlockOutputElement) return;
412
434
  scrollPosition = outputElement.scrollHeight;
413
-
414
- const div = document.createElement('div');
435
+
436
+ const html = ui.processText(text, false).trim();
437
+
438
+ // Previously, we skipped the rest of this if "html" came back as an empty string.
439
+ // But, we _do_ always want the block to be created, in the editor at least - as the
440
+ // author might be in the middle of an edit. When they start writing text for this
441
+ // source (section or passage), we want it to appear in the right place.
442
+ // TODO: What if this comes from a master section/passage though, and there's no
443
+ // text (just a script)? Or if there's conditional text that doesn't display?
444
+
445
+ if (!currentBlockOutputElement) {
446
+ newBlockOutputElement();
447
+ }
448
+
449
+ const div = document.createElement("div");
415
450
  if (source) {
416
- div.setAttribute('data-source', source);
451
+ div.setAttribute("data-source", source);
417
452
  }
453
+
454
+ div.innerHTML = html;
455
+ pluginManager.onWrite(div);
418
456
  currentBlockOutputElement.appendChild(div);
419
- div.innerHTML = ui.processText(text);
420
-
421
457
  ui.scrollToEnd();
422
458
  },
423
459
  clearScreen: () => {
424
- outputElement.innerHTML = '';
425
- newSection(null);
460
+ clearScreen();
461
+ createSectionElement();
426
462
  },
427
463
  scrollToEnd: () => {
428
- if (settings.scroll === 'element') {
429
- const scrollTo = outputElement.scrollHeight - outputElement.clientHeight;
430
- const currentScrollTop = outputElement.scrollTop;
431
- if (scrollTo > (currentScrollTop || 0)) {
432
- outputElement.scrollTo({ top: scrollTo, behavior: 'smooth' });
433
- }
464
+ if (settings.scroll === "none") {
465
+ // do nothing
466
+ }
467
+ else if (settings.scroll === "element") {
468
+ outputElement.lastElementChild.scrollIntoView({ block: "end", inline: "nearest", behavior: "smooth" });
434
469
  }
435
470
  else {
436
471
  let scrollTo = scrollPosition;
@@ -438,103 +473,139 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
438
473
  if (scrollTo > currentScrollTop) {
439
474
  const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
440
475
  if (scrollTo > maxScrollTop) scrollTo = maxScrollTop;
441
- window.scrollTo({ top: scrollTo, behavior: 'smooth' });
476
+ window.scrollTo({ top: scrollTo, behavior: "smooth" });
442
477
  }
443
478
  }
444
479
  },
445
- processText: (text: string) => {
446
- const data = {
447
- fulltext: text
448
- };
449
- return textProcessor.process(text, data);
450
- },
451
- transition: function (f: any) {
452
- set('_transition', f.toString());
453
- f();
480
+ processText: (text: string, inline: boolean) => {
481
+ return textProcessor.process(text, inline);
454
482
  },
455
483
  };
456
484
 
457
- function safeQuerySelector(name: string) {
458
- return name.replace(/'/g, "\\'");
459
- }
485
+ function update(newStory: Story) {
486
+ if (newStory.start != story.start) {
487
+ story = newStory;
488
+ state.reset();
489
+ outputElement.innerHTML = "";
490
+ go(story.start);
491
+ return;
492
+ }
493
+
494
+ updateStory(story, newStory, outputElement, ui, disableLink);
495
+
496
+ story = newStory;
460
497
 
461
- function getSectionContent(section: string) {
462
- return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]]']`);
498
+ setCurrentSectionElement();
499
+ setCurrentPassageElement();
463
500
  }
464
501
 
465
- function getPassageContent(section: string, passage: string) {
466
- return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]][${safeQuerySelector(passage)}]']`);
502
+ function setCurrentSectionElement() {
503
+ // Multiple .squiffy-output-section elements may be "last-child" if some have been moved to the clear-stack,
504
+ // so we want the very last one
505
+ const allSectionElements = outputElement.querySelectorAll<HTMLElement>(".squiffy-output-section:last-child");
506
+ currentSectionElement = allSectionElements[allSectionElements.length - 1];
507
+ const sectionName = currentSectionElement.getAttribute("data-section");
508
+ currentSection = story.sections[sectionName];
467
509
  }
468
510
 
469
- function updateElementTextPreservingDisabledPassageLinks(element: Element, text: string) {
470
- // Record which passage links are disabled
471
- const disabledPassages = Array.from(element
472
- .querySelectorAll("a.link-passage.disabled"))
473
- .map((el: HTMLElement) => el.getAttribute("data-passage"));
511
+ function setCurrentPassageElement() {
512
+ currentPassageElement = currentSectionElement.querySelector(".squiffy-output-passage:last-child");
513
+ }
474
514
 
475
- element.innerHTML = text;
515
+ function getHistoryCount() {
516
+ const clearStack = getClearStack();
517
+ const sectionPassageCount = outputElement.querySelectorAll(".squiffy-output-section").length
518
+ + outputElement.querySelectorAll(".squiffy-output-passage").length;
476
519
 
477
- // Re-disable links that were disabled before the update
478
- for (const passage of disabledPassages) {
479
- const link = element.querySelector(`a.link-passage[data-passage="${passage}"]`);
480
- if (link) disableLink(link);
520
+ if (!clearStack) {
521
+ return sectionPassageCount;
481
522
  }
523
+
524
+ return sectionPassageCount + clearStack.children.length;
482
525
  }
483
526
 
484
- function update(newStory: Story) {
485
- for (const existingSection of Object.keys(story.sections)) {
486
- const elements = getSectionContent(existingSection);
487
- if (elements.length) {
488
- const newSection = newStory.sections[existingSection];
489
- if (!newSection) {
490
- // section has been deleted
491
- for (const element of elements) {
492
- const parentOutputSection = element.closest('.squiffy-output-section');
493
- parentOutputSection.remove();
494
- }
527
+ function canGoBack() {
528
+ return getHistoryCount() > 1;
529
+ }
530
+
531
+ function goBack() {
532
+ if (!canGoBack()) {
533
+ return;
534
+ }
535
+
536
+ const clearStack = getClearStack();
537
+
538
+ if (currentPassageElement) {
539
+ const currentPassage = currentPassageElement.getAttribute("data-passage");
540
+ doUndo(currentPassageElement.getAttribute("data-undo"));
541
+ currentPassageElement.remove();
542
+
543
+ // If there's nothing left in the outputElement except for an empty section element that
544
+ // was created when the screen was cleared, pop the clear-stack.
545
+
546
+ let hasEmptySection = false;
547
+ let hasOtherElements = false;
548
+ for (const child of outputElement.children) {
549
+ if (child === clearStack) {
550
+ continue;
495
551
  }
496
- else if (newSection.text && newSection.text != story.sections[existingSection].text) {
497
- // section has been updated
498
- for (const element of elements) {
499
- updateElementTextPreservingDisabledPassageLinks(element, ui.processText(newSection.text));
500
- }
552
+ if (child.getAttribute("data-clear") == "true" && child.children.length == 0) {
553
+ hasEmptySection = true;
554
+ continue;
501
555
  }
556
+ hasOtherElements = true;
557
+ break;
502
558
  }
503
559
 
504
- if (!story.sections[existingSection].passages) continue;
505
-
506
- for (const existingPassage of Object.keys(story.sections[existingSection].passages)) {
507
- const elements = getPassageContent(existingSection, existingPassage);
508
- if (!elements.length) continue;
560
+ if (hasEmptySection && !hasOtherElements) {
561
+ unClearScreen();
562
+ setCurrentSectionElement();
563
+ }
509
564
 
510
- const newPassage = newStory.sections[existingSection]?.passages && newStory.sections[existingSection]?.passages[existingPassage];
511
- if (!newPassage) {
512
- // passage has been deleted
513
- for (const element of elements) {
514
- const parentOutputBlock = element.closest('.squiffy-output-block');
515
- parentOutputBlock.remove();
516
- }
565
+ for (const link of currentSectionElement.querySelectorAll("a.squiffy-link[data-passage]")) {
566
+ if (link.getAttribute("data-passage") == currentPassage) {
567
+ enableLink(link);
517
568
  }
518
- else if (newPassage.text && newPassage.text != story.sections[existingSection].passages[existingPassage].text) {
519
- // passage has been updated
520
- for (const element of elements) {
521
- updateElementTextPreservingDisabledPassageLinks(element, ui.processText(newPassage.text));
522
- }
569
+ }
570
+
571
+ setCurrentPassageElement();
572
+ }
573
+ else {
574
+ doUndo(currentSectionElement.getAttribute("data-undo"));
575
+ currentSectionElement.remove();
576
+
577
+ // If there's nothing left in the outputElement except for the clear-stack, pop it
578
+ let hasOtherElements = false;
579
+ for (const child of [...outputElement.children]) {
580
+ if (child === clearStack) {
581
+ continue;
523
582
  }
583
+ hasOtherElements = true;
584
+ break;
585
+ }
586
+
587
+ if (!hasOtherElements) {
588
+ unClearScreen();
524
589
  }
590
+
591
+ setCurrentSectionElement();
592
+ setCurrentPassageElement();
525
593
  }
526
594
 
527
- story = newStory;
528
- currentSectionElement = outputElement.querySelector('.squiffy-output-section:last-child');
529
- const sectionName = currentSectionElement.getAttribute('data-section');
530
- currentSection = story.sections[sectionName];
595
+ if (!canGoBack()) {
596
+ emitter.emit("canGoBackChanged", { canGoBack: false });
597
+ }
531
598
  }
532
599
 
533
- outputElement = options.element;
600
+ // We create a separate div inside the passed-in element. This allows us to clear the text output, but
601
+ // without affecting any overlays that may have been added to the container (for transitions).
602
+ const outputElementContainer = options.element;
603
+ const outputElement = document.createElement("div");
604
+ outputElementContainer.appendChild(outputElement);
534
605
  story = options.story;
535
606
 
536
- settings = {
537
- scroll: options.scroll || 'body',
607
+ const settings = {
608
+ scroll: options.scroll || "body",
538
609
  persist: (options.persist === undefined) ? true : options.persist,
539
610
  onSet: options.onSet || (() => {})
540
611
  };
@@ -544,26 +615,83 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
544
615
  settings.persist = false;
545
616
  }
546
617
 
547
- if (settings.scroll === 'element') {
548
- outputElement.style.overflowY = 'auto';
618
+ if (settings.scroll === "element") {
619
+ outputElement.style.overflowY = "auto";
549
620
  }
550
621
 
551
- outputElement.addEventListener('click', handleClick);
552
- outputElement.addEventListener('keypress', function (event) {
622
+ outputElement.addEventListener("click", handleClick);
623
+ outputElement.addEventListener("keypress", async function (event) {
553
624
  if (event.key !== "Enter") return;
554
- handleClick(event);
625
+ await handleClick(event);
555
626
  });
556
627
 
557
- textProcessor = new TextProcessor(get, set, story, () => currentSection, seen, processAttributes);
558
-
559
- begin();
628
+ let undoLog: Record<string, any> = {};
629
+
630
+ const onSet = function(attribute: string, oldValue: any) {
631
+ if (attribute == "_output") return;
632
+ if (attribute in undoLog) return;
633
+ undoLog[attribute] = oldValue;
634
+ };
635
+
636
+ const writeUndoLog = function() {
637
+ (currentPassageElement ?? currentSectionElement).setAttribute("data-undo", JSON.stringify(undoLog));
638
+ undoLog = {};
639
+ };
640
+
641
+ const doUndo = function(undosJson: string | null) {
642
+ if (!undosJson) return;
643
+ const undos = JSON.parse(undosJson) as Record<string, any>;
644
+ if (!undos) return;
645
+ for (const attribute of Object.keys(undos)) {
646
+ state.setInternal(attribute, undos[attribute], false);
647
+ }
648
+ };
649
+
650
+ const state = new State(settings.persist, story.id || "", settings.onSet, emitter, onSet);
651
+ const get = state.get.bind(state);
652
+ const set = state.set.bind(state);
653
+
654
+ const textProcessor = new TextProcessor(story, state, () => currentSection);
655
+ const linkHandler = new LinkHandler();
656
+
657
+ const getSectionText = (sectionName: string) => {
658
+ if (sectionName in story.sections) {
659
+ return story.sections[sectionName].text || null;
660
+ }
661
+ return null;
662
+ };
663
+
664
+ const getPassageText = (name: string) => {
665
+ if (currentSection.passages && name in currentSection.passages) {
666
+ return currentSection.passages[name].text || null;
667
+ } else if ("passages" in story.sections[""] && story.sections[""].passages && name in story.sections[""].passages) {
668
+ return story.sections[""].passages![name].text || null;
669
+ }
670
+ return null;
671
+ };
672
+
673
+ const addTransition = (fn: () => Promise<void>) => {
674
+ transitions.push(fn);
675
+ };
676
+
677
+ const animation = new Animation();
678
+
679
+ const pluginManager = new PluginManager(outputElement, textProcessor, state, linkHandler,
680
+ getSectionText, getPassageText, ui.processText, addTransition, animation, emitter);
681
+ pluginManager.add(Plugins.ReplaceLabel());
682
+ pluginManager.add(Plugins.RotateSequencePlugin());
683
+ pluginManager.add(Plugins.RandomPlugin());
684
+ pluginManager.add(Plugins.LivePlugin());
685
+ pluginManager.add(Plugins.AnimatePlugin());
560
686
 
561
687
  return {
688
+ begin: begin,
562
689
  restart: restart,
563
690
  get: get,
564
691
  set: set,
565
692
  clickLink: handleLink,
566
693
  update: update,
694
+ goBack: goBack,
567
695
  on: (e, h) => emitter.on(e, h),
568
696
  off: (e, h) => emitter.off(e, h),
569
697
  once: (e, h) => emitter.once(e, h),