squiffy-runtime 6.0.0-alpha.2 → 6.0.0-alpha.20

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 (43) hide show
  1. package/LICENSE +22 -0
  2. package/dist/animation.d.ts +11 -0
  3. package/dist/events.d.ts +20 -0
  4. package/dist/import.d.ts +4 -0
  5. package/dist/linkHandler.d.ts +8 -0
  6. package/dist/pluginManager.d.ts +23 -0
  7. package/dist/squiffy.runtime.d.ts +3 -34
  8. package/dist/squiffy.runtime.global.js +126 -0
  9. package/dist/squiffy.runtime.global.js.map +1 -0
  10. package/dist/squiffy.runtime.js +8779 -547
  11. package/dist/squiffy.runtime.js.map +1 -0
  12. package/dist/squiffy.runtime.test.d.ts +1 -0
  13. package/dist/state.d.ts +19 -0
  14. package/dist/textProcessor.d.ts +11 -0
  15. package/dist/types.d.ts +57 -0
  16. package/dist/types.plugins.d.ts +27 -0
  17. package/dist/updater.d.ts +2 -0
  18. package/dist/utils.d.ts +1 -0
  19. package/package.json +26 -5
  20. package/src/__snapshots__/squiffy.runtime.test.ts.snap +138 -0
  21. package/src/animation.ts +68 -0
  22. package/src/events.ts +41 -0
  23. package/src/import.ts +5 -0
  24. package/src/linkHandler.ts +18 -0
  25. package/src/pluginManager.ts +74 -0
  26. package/src/plugins/animate.ts +97 -0
  27. package/src/plugins/index.ts +13 -0
  28. package/src/plugins/live.ts +83 -0
  29. package/src/plugins/random.ts +22 -0
  30. package/src/plugins/replaceLabel.ts +22 -0
  31. package/src/plugins/rotateSequence.ts +61 -0
  32. package/src/squiffy.runtime.test.ts +677 -0
  33. package/src/squiffy.runtime.ts +528 -499
  34. package/src/state.ts +106 -0
  35. package/src/textProcessor.ts +76 -0
  36. package/src/types.plugins.ts +41 -0
  37. package/src/types.ts +81 -0
  38. package/src/updater.ts +77 -0
  39. package/src/utils.ts +17 -0
  40. package/tsconfig.json +4 -1
  41. package/vite.config.ts +36 -0
  42. package/vitest.config.ts +9 -0
  43. package/vitest.setup.ts +16 -0
@@ -1,188 +1,129 @@
1
- interface SquiffyInitOptions {
2
- element: HTMLElement;
3
- story: Story;
4
- scroll?: string,
5
- persist?: boolean,
6
- onSet?: (attribute: string, value: any) => void,
7
- }
8
-
9
- interface SquiffySettings {
10
- scroll: string,
11
- persist: boolean,
12
- onSet: (attribute: string, value: any) => void,
13
- }
14
-
15
- interface SquiffyApi {
16
- restart: () => void;
17
- get: (attribute: string) => any;
18
- set: (attribute: string, value: any) => void;
19
- }
20
-
21
- interface Story {
22
- js: (() => void)[];
23
- start: string;
24
- id?: string | null;
25
- sections: Record<string, Section>;
26
- }
27
-
28
- interface Section {
29
- text?: string;
30
- clear?: boolean;
31
- attributes?: string[],
32
- jsIndex?: number;
33
- passages?: Record<string, Passage>;
34
- passageCount?: number;
35
- }
36
-
37
- interface Passage {
38
- text?: string;
39
- clear?: boolean;
40
- attributes?: string[];
41
- jsIndex?: number;
42
- }
43
-
44
- export const init = (options: SquiffyInitOptions): SquiffyApi => {
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";
11
+
12
+ export type { SquiffyApi } from "./types.js";
13
+
14
+ export const init = async (options: SquiffyInitOptions): Promise<SquiffyApi> => {
45
15
  let story: Story;
46
16
  let currentSection: Section;
47
17
  let currentSectionElement: HTMLElement;
18
+ let currentPassageElement: HTMLElement;
19
+ let currentBlockOutputElement: HTMLElement;
48
20
  let scrollPosition = 0;
49
- let outputElement: HTMLElement;
50
- let settings: SquiffySettings;
51
- let storageFallback: Record<string, string> = {};
52
-
53
- function set(attribute: string, value: any) {
54
- if (typeof value === 'undefined') value = true;
55
- if (settings.persist && window.localStorage) {
56
- localStorage[story.id + '-' + attribute] = JSON.stringify(value);
57
- }
58
- else {
59
- storageFallback[attribute] = JSON.stringify(value);
60
- }
61
- settings.onSet(attribute, value);
62
- }
63
-
64
- function get(attribute: string): any {
65
- let result;
66
- if (settings.persist && window.localStorage) {
67
- result = localStorage[story.id + '-' + attribute];
68
- }
69
- else {
70
- result = storageFallback[attribute];
71
- }
72
- if (!result) return null;
73
- return JSON.parse(result);
74
- }
75
-
76
- function initLinkHandler() {
77
- function handleLink(link: HTMLElement) {
78
- if (link.classList.contains('disabled')) return;
79
- let passage = link.getAttribute('data-passage');
80
- let section = link.getAttribute('data-section');
81
- const rotateAttr = link.getAttribute('data-rotate');
82
- const sequenceAttr = link.getAttribute('data-sequence');
83
- const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
21
+ const emitter = new Emitter<SquiffyEventMap>();
22
+ const transitions: (() => Promise<void>)[] = [];
23
+ let runningTransitions = false;
24
+
25
+ async function handleLink(link: HTMLElement): Promise<boolean> {
26
+ if (runningTransitions) return false;
27
+ const outputSection = link.closest(".squiffy-output-section");
28
+ if (outputSection !== currentSectionElement) return false;
29
+
30
+ if (link.classList.contains("disabled")) return false;
31
+
32
+ const passage = link.getAttribute("data-passage");
33
+ const section = link.getAttribute("data-section");
34
+
35
+ if (passage !== null) {
36
+ disableLink(link);
37
+ set("_turncount", get("_turncount") + 1);
38
+ await processLink(link);
84
39
  if (passage) {
85
- disableLink(link);
86
- set('_turncount', get('_turncount') + 1);
87
- passage = processLink(passage);
88
- if (passage) {
89
- currentSectionElement?.appendChild(document.createElement('hr'));
90
- showPassage(passage);
91
- }
92
- const turnPassage = '@' + get('_turncount');
93
- if (currentSection.passages) {
94
- if (turnPassage in currentSection.passages) {
95
- showPassage(turnPassage);
96
- }
97
- if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
98
- showPassage('@last');
99
- }
100
- }
40
+ currentBlockOutputElement = null;
41
+ await showPassage(passage);
101
42
  }
102
- else if (section) {
103
- currentSectionElement?.appendChild(document.createElement('hr'));
104
- disableLink(link);
105
- section = processLink(section);
106
- if (section) {
107
- go(section);
108
- }
109
- }
110
- else if (rotateOrSequenceAttr) {
111
- const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
112
- link.innerHTML = result[0]!.replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
113
- const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
114
- link.setAttribute(dataAttribute, result[1] || '');
115
- if (!result[1]) {
116
- disableLink(link);
43
+ const turnPassage = "@" + get("_turncount");
44
+ if (currentSection.passages) {
45
+ if (turnPassage in currentSection.passages) {
46
+ await showPassage(turnPassage);
117
47
  }
118
- const attribute = link.getAttribute('data-attribute');
119
- if (attribute) {
120
- set(attribute, result[0]);
48
+ if ("@last" in currentSection.passages && get("_turncount") >= (currentSection.passageCount || 0)) {
49
+ await showPassage("@last");
121
50
  }
122
- save();
123
51
  }
52
+
53
+ emitter.emit("linkClick", { linkType: "passage" });
54
+ return true;
124
55
  }
125
-
126
- function handleClick(event: Event) {
127
- const target = event.target as HTMLElement;
128
- if (target.classList.contains('squiffy-link')) {
129
- handleLink(target);
56
+
57
+ if (section !== null) {
58
+ await processLink(link);
59
+ if (section) {
60
+ await go(section);
130
61
  }
62
+
63
+ emitter.emit("linkClick", { linkType: "section" });
64
+ return true;
65
+ }
66
+
67
+ const [handled, type, result] = linkHandler.handleLink(link);
68
+
69
+ if (handled) {
70
+ if (result?.disableLink) {
71
+ disableLink(link);
72
+ }
73
+
74
+ save();
75
+
76
+ emitter.emit("linkClick", { linkType: type });
77
+ return true;
78
+ }
79
+
80
+ return false;
81
+ }
82
+
83
+ async function handleClick(event: Event) {
84
+ const target = event.target as HTMLElement;
85
+ if (target.classList.contains("squiffy-link")) {
86
+ await handleLink(target);
131
87
  }
132
-
133
- document.addEventListener('click', handleClick);
134
- document.addEventListener('keypress', function (event) {
135
- if (event.key !== "Enter") return;
136
- handleClick(event);
137
- });
138
88
  }
139
89
 
140
90
  function disableLink(link: Element) {
141
- link.classList.add('disabled');
142
- link.setAttribute('tabindex', '-1');
91
+ link.classList.add("disabled");
92
+ link.setAttribute("tabindex", "-1");
143
93
  }
144
-
145
- function disableLinks(links: NodeListOf<Element>) {
146
- links.forEach(disableLink);
94
+
95
+ function enableLink(link: Element) {
96
+ link.classList.remove("disabled");
97
+ link.removeAttribute("tabindex");
147
98
  }
148
99
 
149
- function begin() {
100
+ async function begin() {
150
101
  if (!load()) {
151
- go(story.start);
102
+ await go(story.start);
152
103
  }
153
104
  }
154
105
 
155
- function processLink(link: string): string | null {
156
- const sections = link.split(',');
157
- let first = true;
158
- let target: string | null = null;
159
- sections.forEach(function (section) {
160
- section = section.trim();
161
- if (startsWith(section, '@replace ')) {
162
- replaceLabel(section.substring(9));
163
- }
164
- else {
165
- if (first) {
166
- target = section;
167
- }
168
- else {
169
- setAttribute(section);
170
- }
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);
171
114
  }
172
- first = false;
173
- });
174
- return target;
115
+ }
175
116
  }
176
117
 
177
118
  function setAttribute(expr: string) {
178
- expr = expr.replace(/^(\w*\s*):=(.*)$/, (_, name, value) => (name + "=" + ui.processText(value)));
119
+ expr = expr.replace(/^(\w*\s*):=(.*)$/, (_, name, value) => (name + "=" + ui.processText(value, true)));
179
120
  const setRegex = /^([\w]*)\s*=\s*(.*)$/;
180
121
  const setMatch = setRegex.exec(expr);
181
122
  if (setMatch) {
182
123
  const lhs = setMatch[1];
183
124
  let rhs = setMatch[2];
184
125
  if (isNaN(rhs as any)) {
185
- if (startsWith(rhs, "@")) rhs = get(rhs.substring(1));
126
+ if (rhs.startsWith("@")) rhs = get(rhs.substring(1));
186
127
  set(lhs, rhs);
187
128
  }
188
129
  else {
@@ -190,33 +131,33 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
190
131
  }
191
132
  }
192
133
  else {
193
- const incDecRegex = /^([\w]*)\s*([\+\-\*\/])=\s*(.*)$/;
134
+ const incDecRegex = /^([\w]*)\s*([+\-*/])=\s*(.*)$/;
194
135
  const incDecMatch = incDecRegex.exec(expr);
195
136
  if (incDecMatch) {
196
137
  const lhs = incDecMatch[1];
197
138
  const op = incDecMatch[2];
198
139
  let rhs = incDecMatch[3];
199
- if (startsWith(rhs, "@")) rhs = get(rhs.substring(1));
140
+ if (rhs.startsWith("@")) rhs = get(rhs.substring(1));
200
141
  const rhsNumeric = parseFloat(rhs);
201
142
  let value = get(lhs);
202
143
  if (value === null) value = 0;
203
- if (op == '+') {
144
+ if (op == "+") {
204
145
  value += rhsNumeric;
205
146
  }
206
- if (op == '-') {
147
+ if (op == "-") {
207
148
  value -= rhsNumeric;
208
149
  }
209
- if (op == '*') {
150
+ if (op == "*") {
210
151
  value *= rhsNumeric;
211
152
  }
212
- if (op == '/') {
153
+ if (op == "/") {
213
154
  value /= rhsNumeric;
214
155
  }
215
156
  set(lhs, value);
216
157
  }
217
158
  else {
218
159
  let value = true;
219
- if (startsWith(expr, 'not ')) {
160
+ if (expr.startsWith("not ")) {
220
161
  expr = expr.substring(4);
221
162
  value = false;
222
163
  }
@@ -224,123 +165,143 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
224
165
  }
225
166
  }
226
167
  }
227
-
228
- function replaceLabel(expr: string) {
229
- const regex = /^([\w]*)\s*=\s*(.*)$/;
230
- const match = regex.exec(expr);
231
- if (!match) return;
232
- const label = match[1];
233
- let text = match[2];
234
- if (currentSection.passages && text in currentSection.passages) {
235
- text = currentSection.passages[text].text || '';
236
- }
237
- else if (text in story.sections) {
238
- text = story.sections[text].text || '';
239
- }
240
- const stripParags = /^<p>(.*)<\/p>$/;
241
- const stripParagsMatch = stripParags.exec(text);
242
- if (stripParagsMatch) {
243
- text = stripParagsMatch[1];
244
- }
245
-
246
- const labelElement = outputElement.querySelector('.squiffy-label-' + label);
247
- if (!labelElement) return;
248
-
249
- labelElement.addEventListener('transitionend', function () {
250
- labelElement.innerHTML = ui.processText(text);
251
-
252
- labelElement.addEventListener('transitionend', function () {
253
- save();
254
- }, { once: true });
255
-
256
- labelElement.classList.remove('fade-out');
257
- labelElement.classList.add('fade-in');
258
- }, { once: true });
259
-
260
- labelElement.classList.add('fade-out');
261
- }
262
-
263
- function go(section: string) {
264
- set('_transition', null);
265
- newSection();
266
- currentSection = story.sections[section];
168
+
169
+ async function go(sectionName: string) {
170
+ const oldCanGoBack = canGoBack();
171
+ newSection(sectionName);
172
+ currentSection = story.sections[sectionName];
267
173
  if (!currentSection) return;
268
- set('_section', section);
269
- setSeen(section);
270
- 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
+ }
271
180
  if (master) {
272
- run(master);
273
- ui.write(master.text || '');
181
+ await run(master, "[[]]");
274
182
  }
275
- run(currentSection);
183
+ await run(currentSection, `[[${sectionName}]]`);
276
184
  // The JS might have changed which section we're in
277
- if (get('_section') == section) {
278
- set('_turncount', 0);
279
- ui.write(currentSection.text || '');
185
+ if (get("_section") == sectionName) {
186
+ set("_turncount", 0);
187
+ writeUndoLog();
280
188
  save();
281
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);
282
213
  }
283
214
 
284
- function run(section: Section) {
285
- if (section.clear) {
286
- ui.clearScreen();
287
- }
215
+ async function run(section: Section, source: string) {
288
216
  if (section.attributes) {
289
- 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);
290
218
  }
291
219
  if (section.jsIndex !== undefined) {
292
- story.js[section.jsIndex]();
220
+ runJs(section.jsIndex);
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();
293
233
  }
234
+ transitions.length = 0;
235
+ runningTransitions = false;
236
+ currentSectionElement.classList.remove("links-disabled");
294
237
  }
295
238
 
296
- function showPassage(passageName: string) {
239
+ async function showPassage(passageName: string) {
240
+ const oldCanGoBack = canGoBack();
297
241
  let passage = currentSection.passages && currentSection.passages[passageName];
298
- const masterSection = story.sections[''];
242
+ const masterSection = story.sections[""];
299
243
  if (!passage && masterSection && masterSection.passages) passage = masterSection.passages[passageName];
300
- if (!passage) return;
301
- setSeen(passageName);
244
+ if (!passage) {
245
+ throw `No passage named ${passageName} in the current section or master section`;
246
+ }
247
+ state.setSeen(passageName);
248
+
249
+ const passages: Passage[] = [];
250
+ const runFns: (() => Promise<void>)[] = [];
251
+
302
252
  if (masterSection && masterSection.passages) {
303
- const masterPassage = masterSection.passages[''];
253
+ const masterPassage = masterSection.passages[""];
304
254
  if (masterPassage) {
305
- run(masterPassage);
306
- ui.write(masterPassage.text || '');
255
+ passages.push(masterPassage);
256
+ runFns.push(() => run(masterPassage, "[[]][]"));
307
257
  }
308
258
  }
309
- const master = currentSection.passages && currentSection.passages[''];
259
+
260
+ const master = currentSection.passages && currentSection.passages[""];
310
261
  if (master) {
311
- run(master);
312
- ui.write(master.text || '');
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();
313
283
  }
314
- run(passage);
315
- ui.write(passage.text || '');
284
+
285
+ writeUndoLog();
316
286
  save();
287
+ const newCanGoBack = canGoBack();
288
+ if (newCanGoBack != oldCanGoBack) {
289
+ emitter.emit("canGoBackChanged", { canGoBack: newCanGoBack });
290
+ }
317
291
  }
318
292
 
319
- function processAttributes(attributes: string[]) {
320
- attributes.forEach(function (attribute) {
321
- if (startsWith(attribute, '@replace ')) {
322
- replaceLabel(attribute.substring(9));
323
- }
324
- else {
325
- setAttribute(attribute);
326
- }
327
- });
293
+ async function processAttributes(attributes: string[]) {
294
+ for (const attribute of attributes) {
295
+ setAttribute(attribute);
296
+ }
328
297
  }
329
298
 
330
299
  function restart() {
331
- if (settings.persist && window.localStorage && story.id) {
332
- const keys = Object.keys(localStorage);
333
- for (const key of keys) {
334
- if (startsWith(key, story.id)) {
335
- localStorage.removeItem(key);
336
- }
337
- }
338
- }
339
- else {
340
- storageFallback = {};
341
- }
342
- if (settings.scroll === 'element') {
343
- 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 = "";
344
305
  begin();
345
306
  }
346
307
  else {
@@ -349,94 +310,162 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
349
310
  }
350
311
 
351
312
  function save() {
352
- set('_output', outputElement.innerHTML);
313
+ // TODO: Queue up all attribute changes and save them only when this is called
314
+ set("_output", outputElement.innerHTML);
353
315
  }
354
316
 
355
317
  function load() {
356
- const output = get('_output');
357
- if (!output) return false;
358
- outputElement.innerHTML = output;
359
- const element = document.getElementById(get('_output-section'));
360
- if (!element) return false;
361
- currentSectionElement = element;
362
- currentSection = story.sections[get('_section')];
363
- const transition = get('_transition');
364
- if (transition) {
365
- eval('(' + transition + ')()');
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;
366
331
  }
332
+
333
+ outputElement.innerHTML = output;
334
+
335
+ setCurrentSectionElement();
336
+ setCurrentPassageElement();
337
+ currentBlockOutputElement = outputElement.querySelector(".squiffy-output-block:last-child");
338
+
339
+ currentSection = story.sections[get("_section")];
340
+ runUiJs();
341
+ pluginManager.onLoad();
367
342
  return true;
368
343
  }
369
-
370
- function setSeen(sectionName: string) {
371
- let seenSections = get('_seen_sections');
372
- if (!seenSections) seenSections = [];
373
- if (seenSections.indexOf(sectionName) == -1) {
374
- seenSections.push(sectionName);
375
- set('_seen_sections', seenSections);
376
- }
377
- }
378
-
379
- function seen(sectionName: string) {
380
- const seenSections = get('_seen_sections');
381
- if (!seenSections) return false;
382
- return (seenSections.indexOf(sectionName) > -1);
344
+
345
+ function newBlockOutputElement() {
346
+ currentBlockOutputElement = document.createElement("div");
347
+ currentBlockOutputElement.classList.add("squiffy-output-block");
348
+ (currentPassageElement || currentSectionElement)?.appendChild(currentBlockOutputElement);
383
349
  }
384
350
 
385
- function newSection() {
351
+ function newSection(sectionName?: string) {
386
352
  if (currentSectionElement) {
387
- disableLinks(currentSectionElement.querySelectorAll('.squiffy-link'));
388
- currentSectionElement.querySelectorAll('input').forEach(el => {
389
- const attribute = el.getAttribute('data-attribute') || el.id;
353
+ currentSectionElement.querySelectorAll("input").forEach(el => {
354
+ const attribute = el.getAttribute("data-attribute") || el.id;
390
355
  if (attribute) set(attribute, el.value);
391
356
  el.disabled = true;
392
357
  });
393
358
 
394
359
  currentSectionElement.querySelectorAll("[contenteditable]").forEach(el => {
395
- const attribute = el.getAttribute('data-attribute') || el.id;
360
+ const attribute = el.getAttribute("data-attribute") || el.id;
396
361
  if (attribute) set(attribute, el.innerHTML);
397
- (el as HTMLElement).contentEditable = 'false';
362
+ (el as HTMLElement).contentEditable = "false";
398
363
  });
399
364
 
400
- currentSectionElement.querySelectorAll('textarea').forEach(el => {
401
- const attribute = el.getAttribute('data-attribute') || el.id;
365
+ currentSectionElement.querySelectorAll("textarea").forEach(el => {
366
+ const attribute = el.getAttribute("data-attribute") || el.id;
402
367
  if (attribute) set(attribute, el.value);
403
368
  el.disabled = true;
404
369
  });
405
370
  }
406
-
407
- const sectionCount = get('_section-count') + 1;
408
- set('_section-count', sectionCount);
409
- const id = 'squiffy-section-' + sectionCount;
410
-
411
- currentSectionElement = document.createElement('div');
412
- currentSectionElement.id = id;
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");
383
+ }
413
384
  outputElement.appendChild(currentSectionElement);
414
-
415
- set('_output-section', id);
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();
416
430
  }
417
431
 
418
432
  const ui = {
419
- write: (text: string) => {
420
- if (!currentSectionElement) return;
433
+ write: (text: string, source: string) => {
421
434
  scrollPosition = outputElement.scrollHeight;
422
-
423
- const div = document.createElement('div');
424
- currentSectionElement.appendChild(div);
425
- div.innerHTML = ui.processText(text);
426
-
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");
450
+ if (source) {
451
+ div.setAttribute("data-source", source);
452
+ }
453
+
454
+ div.innerHTML = html;
455
+ pluginManager.onWrite(div);
456
+ currentBlockOutputElement.appendChild(div);
427
457
  ui.scrollToEnd();
428
458
  },
429
459
  clearScreen: () => {
430
- outputElement.innerHTML = '';
431
- newSection();
460
+ clearScreen();
461
+ createSectionElement();
432
462
  },
433
463
  scrollToEnd: () => {
434
- if (settings.scroll === 'element') {
435
- const scrollTo = outputElement.scrollHeight - outputElement.clientHeight;
436
- const currentScrollTop = outputElement.scrollTop;
437
- if (scrollTo > (currentScrollTop || 0)) {
438
- outputElement.scrollTo({ top: scrollTo, behavior: 'smooth' });
439
- }
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" });
440
469
  }
441
470
  else {
442
471
  let scrollTo = scrollPosition;
@@ -444,208 +473,139 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
444
473
  if (scrollTo > currentScrollTop) {
445
474
  const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
446
475
  if (scrollTo > maxScrollTop) scrollTo = maxScrollTop;
447
- window.scrollTo({ top: scrollTo, behavior: 'smooth' });
476
+ window.scrollTo({ top: scrollTo, behavior: "smooth" });
448
477
  }
449
478
  }
450
479
  },
451
- processText: (text: string) => {
452
- function process(text: string, data: any) {
453
- let containsUnprocessedSection = false;
454
- const open = text.indexOf('{');
455
- let close;
456
-
457
- if (open > -1) {
458
- let nestCount = 1;
459
- let searchStart = open + 1;
460
- let finished = false;
461
-
462
- while (!finished) {
463
- const nextOpen = text.indexOf('{', searchStart);
464
- const nextClose = text.indexOf('}', searchStart);
465
-
466
- if (nextClose > -1) {
467
- if (nextOpen > -1 && nextOpen < nextClose) {
468
- nestCount++;
469
- searchStart = nextOpen + 1;
470
- }
471
- else {
472
- nestCount--;
473
- searchStart = nextClose + 1;
474
- if (nestCount === 0) {
475
- close = nextClose;
476
- containsUnprocessedSection = true;
477
- finished = true;
478
- }
479
- }
480
- }
481
- else {
482
- finished = true;
483
- }
484
- }
485
- }
486
-
487
- if (containsUnprocessedSection) {
488
- const section = text.substring(open + 1, close);
489
- const value = processTextCommand(section, data);
490
- text = text.substring(0, open) + value + process(text.substring(close! + 1), data);
491
- }
492
-
493
- return (text);
480
+ processText: (text: string, inline: boolean) => {
481
+ return textProcessor.process(text, inline);
482
+ },
483
+ };
484
+
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;
497
+
498
+ setCurrentSectionElement();
499
+ setCurrentPassageElement();
500
+ }
501
+
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];
509
+ }
510
+
511
+ function setCurrentPassageElement() {
512
+ currentPassageElement = currentSectionElement.querySelector(".squiffy-output-passage:last-child");
513
+ }
514
+
515
+ function getHistoryCount() {
516
+ const clearStack = getClearStack();
517
+ const sectionPassageCount = outputElement.querySelectorAll(".squiffy-output-section").length
518
+ + outputElement.querySelectorAll(".squiffy-output-passage").length;
519
+
520
+ if (!clearStack) {
521
+ return sectionPassageCount;
522
+ }
523
+
524
+ return sectionPassageCount + clearStack.children.length;
525
+ }
526
+
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;
551
+ }
552
+ if (child.getAttribute("data-clear") == "true" && child.children.length == 0) {
553
+ hasEmptySection = true;
554
+ continue;
555
+ }
556
+ hasOtherElements = true;
557
+ break;
494
558
  }
495
-
496
- function processTextCommand(text: string, data: any) {
497
- if (startsWith(text, 'if ')) {
498
- return processTextCommand_If(text, data);
499
- }
500
- else if (startsWith(text, 'else:')) {
501
- return processTextCommand_Else(text, data);
502
- }
503
- else if (startsWith(text, 'label:')) {
504
- return processTextCommand_Label(text, data);
505
- }
506
- else if (/^rotate[: ]/.test(text)) {
507
- return processTextCommand_Rotate('rotate', text);
508
- }
509
- else if (/^sequence[: ]/.test(text)) {
510
- return processTextCommand_Rotate('sequence', text);
511
- }
512
- else if (currentSection.passages && text in currentSection.passages) {
513
- return process(currentSection.passages[text].text || '', data);
514
- }
515
- else if (text in story.sections) {
516
- return process(story.sections[text].text || '', data);
517
- }
518
- else if (startsWith(text, '@') && !startsWith(text, '@replace')) {
519
- processAttributes(text.substring(1).split(","));
520
- return "";
521
- }
522
- return get(text);
559
+
560
+ if (hasEmptySection && !hasOtherElements) {
561
+ unClearScreen();
562
+ setCurrentSectionElement();
523
563
  }
524
-
525
- function processTextCommand_If(section: string, data: any) {
526
- const command = section.substring(3);
527
- const colon = command.indexOf(':');
528
- if (colon == -1) {
529
- return ('{if ' + command + '}');
530
- }
531
-
532
- const text = command.substring(colon + 1);
533
- let condition = command.substring(0, colon);
534
- condition = condition.replace("<", "&lt;");
535
- const operatorRegex = /([\w ]*)(=|&lt;=|&gt;=|&lt;&gt;|&lt;|&gt;)(.*)/;
536
- const match = operatorRegex.exec(condition);
537
-
538
- let result = false;
539
-
540
- if (match) {
541
- const lhs = get(match[1]);
542
- const op = match[2];
543
- let rhs = match[3];
544
-
545
- if (startsWith(rhs, '@')) rhs = get(rhs.substring(1));
546
-
547
- if (op == '=' && lhs == rhs) result = true;
548
- if (op == '&lt;&gt;' && lhs != rhs) result = true;
549
- if (op == '&gt;' && lhs > rhs) result = true;
550
- if (op == '&lt;' && lhs < rhs) result = true;
551
- if (op == '&gt;=' && lhs >= rhs) result = true;
552
- if (op == '&lt;=' && lhs <= rhs) result = true;
553
- }
554
- else {
555
- let checkValue = true;
556
- if (startsWith(condition, 'not ')) {
557
- condition = condition.substring(4);
558
- checkValue = false;
559
- }
560
-
561
- if (startsWith(condition, 'seen ')) {
562
- result = (seen(condition.substring(5)) == checkValue);
563
- }
564
- else {
565
- let value = get(condition);
566
- if (value === null) value = false;
567
- result = (value == checkValue);
568
- }
564
+
565
+ for (const link of currentSectionElement.querySelectorAll("a.squiffy-link[data-passage]")) {
566
+ if (link.getAttribute("data-passage") == currentPassage) {
567
+ enableLink(link);
569
568
  }
570
-
571
- const textResult = result ? process(text, data) : '';
572
-
573
- data.lastIf = result;
574
- return textResult;
575
- }
576
-
577
- function processTextCommand_Else(section: string, data: any) {
578
- if (!('lastIf' in data) || data.lastIf) return '';
579
- const text = section.substring(5);
580
- return process(text, data);
581
569
  }
582
-
583
- function processTextCommand_Label(section: string, data: any) {
584
- const command = section.substring(6);
585
- const eq = command.indexOf('=');
586
- if (eq == -1) {
587
- return ('{label:' + command + '}');
588
- }
589
-
590
- const text = command.substring(eq + 1);
591
- const label = command.substring(0, eq);
592
-
593
- return '<span class="squiffy-label-' + label + '">' + process(text, data) + '</span>';
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;
582
+ }
583
+ hasOtherElements = true;
584
+ break;
594
585
  }
595
-
596
- function processTextCommand_Rotate(type: string, section: string) {
597
- let options;
598
- let attribute = '';
599
- if (section.substring(type.length, type.length + 1) == ' ') {
600
- const colon = section.indexOf(':');
601
- if (colon == -1) {
602
- return '{' + section + '}';
603
- }
604
- options = section.substring(colon + 1);
605
- attribute = section.substring(type.length + 1, colon);
606
- }
607
- else {
608
- options = section.substring(type.length + 1);
609
- }
610
- // TODO: Check - previously there was no second parameter here
611
- const rotation = rotate(options.replace(/"/g, '&quot;').replace(/'/g, '&#39;'), null);
612
- if (attribute) {
613
- set(attribute, rotation[0]);
614
- }
615
- return '<a class="squiffy-link" data-' + type + '="' + rotation[1] + '" data-attribute="' + attribute + '" role="link">' + rotation[0] + '</a>';
586
+
587
+ if (!hasOtherElements) {
588
+ unClearScreen();
616
589
  }
617
-
618
- const data = {
619
- fulltext: text
620
- };
621
- return process(text, data);
622
- },
623
- transition: function (f: any) {
624
- set('_transition', f.toString());
625
- f();
626
- },
627
- };
628
-
629
- function startsWith(string: string, prefix: string) {
630
- return string.substring(0, prefix.length) === prefix;
631
- }
632
-
633
- function rotate(options: string, current: string | null) {
634
- const colon = options.indexOf(':');
635
- if (colon == -1) {
636
- return [options, current];
637
- }
638
- const next = options.substring(0, colon);
639
- let remaining = options.substring(colon + 1);
640
- if (current) remaining += ':' + current;
641
- return [next, remaining];
590
+
591
+ setCurrentSectionElement();
592
+ setCurrentPassageElement();
593
+ }
594
+
595
+ if (!canGoBack()) {
596
+ emitter.emit("canGoBackChanged", { canGoBack: false });
597
+ }
642
598
  }
643
599
 
644
- 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);
645
605
  story = options.story;
646
606
 
647
- settings = {
648
- scroll: options.scroll || 'body',
607
+ const settings = {
608
+ scroll: options.scroll || "body",
649
609
  persist: (options.persist === undefined) ? true : options.persist,
650
610
  onSet: options.onSet || (() => {})
651
611
  };
@@ -655,16 +615,85 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
655
615
  settings.persist = false;
656
616
  }
657
617
 
658
- if (settings.scroll === 'element') {
659
- outputElement.style.overflowY = 'auto';
618
+ if (settings.scroll === "element") {
619
+ outputElement.style.overflowY = "auto";
660
620
  }
661
621
 
662
- initLinkHandler();
663
- begin();
622
+ outputElement.addEventListener("click", handleClick);
623
+ outputElement.addEventListener("keypress", async function (event) {
624
+ if (event.key !== "Enter") return;
625
+ await handleClick(event);
626
+ });
627
+
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());
664
686
 
665
687
  return {
688
+ begin: begin,
666
689
  restart: restart,
667
690
  get: get,
668
691
  set: set,
692
+ clickLink: handleLink,
693
+ update: update,
694
+ goBack: goBack,
695
+ on: (e, h) => emitter.on(e, h),
696
+ off: (e, h) => emitter.off(e, h),
697
+ once: (e, h) => emitter.once(e, h),
669
698
  };
670
699
  };