squiffy-runtime 6.0.0-alpha.1 → 6.0.0-alpha.11

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.
@@ -1,54 +1,19 @@
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
- }
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';
43
5
 
44
6
  export const init = (options: SquiffyInitOptions): SquiffyApi => {
45
7
  let story: Story;
46
8
  let currentSection: Section;
47
9
  let currentSectionElement: HTMLElement;
10
+ let currentBlockOutputElement: HTMLElement;
48
11
  let scrollPosition = 0;
49
12
  let outputElement: HTMLElement;
50
13
  let settings: SquiffySettings;
51
14
  let storageFallback: Record<string, string> = {};
15
+ let textProcessor: TextProcessor;
16
+ const emitter = new Emitter<SquiffyEventMap>();
52
17
 
53
18
  function set(attribute: string, value: any) {
54
19
  if (typeof value === 'undefined') value = true;
@@ -73,68 +38,75 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
73
38
  return JSON.parse(result);
74
39
  }
75
40
 
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;
41
+ function handleLink(link: HTMLElement): boolean {
42
+ const outputSection = link.closest('.squiffy-output-section');
43
+ if (outputSection !== currentSectionElement) return false;
44
+
45
+ if (link.classList.contains('disabled')) return false;
46
+
47
+ let passage = link.getAttribute('data-passage');
48
+ let section = link.getAttribute('data-section');
49
+ const rotateAttr = link.getAttribute('data-rotate');
50
+ const sequenceAttr = link.getAttribute('data-sequence');
51
+ const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
52
+ if (passage) {
53
+ disableLink(link);
54
+ set('_turncount', get('_turncount') + 1);
55
+ passage = processLink(passage);
84
56
  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);
57
+ newBlockOutputElement();
58
+ showPassage(passage);
59
+ }
60
+ const turnPassage = '@' + get('_turncount');
61
+ if (currentSection.passages) {
62
+ if (turnPassage in currentSection.passages) {
63
+ showPassage(turnPassage);
91
64
  }
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
- }
65
+ if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
66
+ showPassage('@last');
100
67
  }
101
68
  }
102
- else if (section) {
103
- currentSectionElement?.appendChild(document.createElement('hr'));
69
+
70
+ emitter.emit('linkClick', { linkType: 'passage' });
71
+ return true;
72
+ }
73
+
74
+ if (section) {
75
+ section = processLink(section);
76
+ if (section) {
77
+ go(section);
78
+ }
79
+
80
+ emitter.emit('linkClick', { linkType: 'section' });
81
+ return true;
82
+ }
83
+
84
+ if (rotateOrSequenceAttr) {
85
+ const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
86
+ link.innerHTML = result[0]!.replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
87
+ const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
88
+ link.setAttribute(dataAttribute, result[1] || '');
89
+ if (!result[1]) {
104
90
  disableLink(link);
105
- section = processLink(section);
106
- if (section) {
107
- go(section);
108
- }
109
91
  }
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);
117
- }
118
- const attribute = link.getAttribute('data-attribute');
119
- if (attribute) {
120
- set(attribute, result[0]);
121
- }
122
- save();
92
+ const attribute = link.getAttribute('data-attribute');
93
+ if (attribute) {
94
+ set(attribute, result[0]);
123
95
  }
96
+ save();
97
+
98
+ emitter.emit('linkClick', { linkType: rotateAttr ? 'rotate' : 'sequence' });
99
+ return true;
124
100
  }
125
-
126
- function handleClick(event: Event) {
127
- const target = event.target as HTMLElement;
128
- if (target.classList.contains('squiffy-link')) {
129
- handleLink(target);
130
- }
101
+
102
+ return false;
103
+ }
104
+
105
+ function handleClick(event: Event) {
106
+ const target = event.target as HTMLElement;
107
+ if (target.classList.contains('squiffy-link')) {
108
+ handleLink(target);
131
109
  }
132
-
133
- document.addEventListener('click', handleClick);
134
- document.addEventListener('keypress', function (event) {
135
- if (event.key !== "Enter") return;
136
- handleClick(event);
137
- });
138
110
  }
139
111
 
140
112
  function disableLink(link: Element) {
@@ -142,10 +114,6 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
142
114
  link.setAttribute('tabindex', '-1');
143
115
  }
144
116
 
145
- function disableLinks(links: NodeListOf<Element>) {
146
- links.forEach(disableLink);
147
- }
148
-
149
117
  function begin() {
150
118
  if (!load()) {
151
119
  go(story.start);
@@ -260,23 +228,23 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
260
228
  labelElement.classList.add('fade-out');
261
229
  }
262
230
 
263
- function go(section: string) {
231
+ function go(sectionName: string) {
264
232
  set('_transition', null);
265
- newSection();
266
- currentSection = story.sections[section];
233
+ newSection(sectionName);
234
+ currentSection = story.sections[sectionName];
267
235
  if (!currentSection) return;
268
- set('_section', section);
269
- setSeen(section);
236
+ set('_section', sectionName);
237
+ setSeen(sectionName);
270
238
  const master = story.sections[''];
271
239
  if (master) {
272
240
  run(master);
273
- ui.write(master.text || '');
241
+ ui.write(master.text || '', "[[]]");
274
242
  }
275
243
  run(currentSection);
276
244
  // The JS might have changed which section we're in
277
- if (get('_section') == section) {
245
+ if (get('_section') == sectionName) {
278
246
  set('_turncount', 0);
279
- ui.write(currentSection.text || '');
247
+ ui.write(currentSection.text || '', `[[${sectionName}]]`);
280
248
  save();
281
249
  }
282
250
  }
@@ -289,7 +257,17 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
289
257
  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)));
290
258
  }
291
259
  if (section.jsIndex !== undefined) {
292
- story.js[section.jsIndex]();
260
+ const squiffy = {
261
+ get: get,
262
+ set: set,
263
+ ui: {
264
+ transition: ui.transition,
265
+ },
266
+ story: {
267
+ go: go,
268
+ },
269
+ };
270
+ story.js[section.jsIndex](squiffy, get, set);
293
271
  }
294
272
  }
295
273
 
@@ -297,22 +275,24 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
297
275
  let passage = currentSection.passages && currentSection.passages[passageName];
298
276
  const masterSection = story.sections[''];
299
277
  if (!passage && masterSection && masterSection.passages) passage = masterSection.passages[passageName];
300
- if (!passage) return;
278
+ if (!passage) {
279
+ throw `No passage named ${passageName} in the current section or master section`;
280
+ }
301
281
  setSeen(passageName);
302
282
  if (masterSection && masterSection.passages) {
303
283
  const masterPassage = masterSection.passages[''];
304
284
  if (masterPassage) {
305
285
  run(masterPassage);
306
- ui.write(masterPassage.text || '');
286
+ ui.write(masterPassage.text || '', `[[]][]`);
307
287
  }
308
288
  }
309
289
  const master = currentSection.passages && currentSection.passages[''];
310
290
  if (master) {
311
291
  run(master);
312
- ui.write(master.text || '');
292
+ ui.write(master.text || '', `[[${get("_section")}]][]`);
313
293
  }
314
294
  run(passage);
315
- ui.write(passage.text || '');
295
+ ui.write(passage.text || '', `[[${get("_section")}]][${passageName}]`);
316
296
  save();
317
297
  }
318
298
 
@@ -356,9 +336,10 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
356
336
  const output = get('_output');
357
337
  if (!output) return false;
358
338
  outputElement.innerHTML = output;
359
- const element = document.getElementById(get('_output-section'));
360
- if (!element) return false;
361
- currentSectionElement = element;
339
+
340
+ currentSectionElement = outputElement.querySelector('.squiffy-output-section:last-child');
341
+ currentBlockOutputElement = outputElement.querySelector('.squiffy-output-block:last-child');
342
+
362
343
  currentSection = story.sections[get('_section')];
363
344
  const transition = get('_transition');
364
345
  if (transition) {
@@ -381,10 +362,15 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
381
362
  if (!seenSections) return false;
382
363
  return (seenSections.indexOf(sectionName) > -1);
383
364
  }
365
+
366
+ function newBlockOutputElement() {
367
+ currentBlockOutputElement = document.createElement('div');
368
+ currentBlockOutputElement.classList.add('squiffy-output-block');
369
+ currentSectionElement?.appendChild(currentBlockOutputElement);
370
+ }
384
371
 
385
- function newSection() {
372
+ function newSection(sectionName: string | null) {
386
373
  if (currentSectionElement) {
387
- disableLinks(currentSectionElement.querySelectorAll('.squiffy-link'));
388
374
  currentSectionElement.querySelectorAll('input').forEach(el => {
389
375
  const attribute = el.getAttribute('data-attribute') || el.id;
390
376
  if (attribute) set(attribute, el.value);
@@ -409,26 +395,32 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
409
395
  const id = 'squiffy-section-' + sectionCount;
410
396
 
411
397
  currentSectionElement = document.createElement('div');
398
+ currentSectionElement.classList.add('squiffy-output-section');
412
399
  currentSectionElement.id = id;
400
+ if (sectionName) {
401
+ currentSectionElement.setAttribute('data-section', `${sectionName}`);
402
+ }
413
403
  outputElement.appendChild(currentSectionElement);
414
-
415
- set('_output-section', id);
404
+ newBlockOutputElement();
416
405
  }
417
406
 
418
407
  const ui = {
419
- write: (text: string) => {
420
- if (!currentSectionElement) return;
408
+ write: (text: string, source: string) => {
409
+ if (!currentBlockOutputElement) return;
421
410
  scrollPosition = outputElement.scrollHeight;
422
411
 
423
412
  const div = document.createElement('div');
424
- currentSectionElement.appendChild(div);
413
+ if (source) {
414
+ div.setAttribute('data-source', source);
415
+ }
416
+ currentBlockOutputElement.appendChild(div);
425
417
  div.innerHTML = ui.processText(text);
426
418
 
427
419
  ui.scrollToEnd();
428
420
  },
429
421
  clearScreen: () => {
430
422
  outputElement.innerHTML = '';
431
- newSection();
423
+ newSection(null);
432
424
  },
433
425
  scrollToEnd: () => {
434
426
  if (settings.scroll === 'element') {
@@ -449,196 +441,91 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
449
441
  }
450
442
  },
451
443
  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);
494
- }
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);
523
- }
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
- }
569
- }
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
- }
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>';
594
- }
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>';
616
- }
617
-
618
444
  const data = {
619
445
  fulltext: text
620
446
  };
621
- return process(text, data);
447
+ return textProcessor.process(text, data);
622
448
  },
623
449
  transition: function (f: any) {
624
450
  set('_transition', f.toString());
625
451
  f();
626
452
  },
627
453
  };
628
-
629
- function startsWith(string: string, prefix: string) {
630
- return string.substring(0, prefix.length) === prefix;
454
+
455
+ function safeQuerySelector(name: string) {
456
+ return name.replace(/'/g, "\\'");
631
457
  }
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];
458
+
459
+ function getSectionContent(section: string) {
460
+ return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]]']`);
461
+ }
462
+
463
+ function getPassageContent(section: string, passage: string) {
464
+ return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]][${safeQuerySelector(passage)}]']`);
465
+ }
466
+
467
+ function updateElementTextPreservingDisabledPassageLinks(element: Element, text: string) {
468
+ // Record which passage links are disabled
469
+ const disabledPassages = Array.from(element
470
+ .querySelectorAll("a.link-passage.disabled"))
471
+ .map((el: HTMLElement) => el.getAttribute("data-passage"));
472
+
473
+ element.innerHTML = text;
474
+
475
+ // Re-disable links that were disabled before the update
476
+ for (const passage of disabledPassages) {
477
+ const link = element.querySelector(`a.link-passage[data-passage="${passage}"]`);
478
+ if (link) disableLink(link);
479
+ }
480
+ }
481
+
482
+ function update(newStory: Story) {
483
+ for (const existingSection of Object.keys(story.sections)) {
484
+ const elements = getSectionContent(existingSection);
485
+ if (elements.length) {
486
+ const newSection = newStory.sections[existingSection];
487
+ if (!newSection) {
488
+ // section has been deleted
489
+ for (const element of elements) {
490
+ const parentOutputSection = element.closest('.squiffy-output-section');
491
+ parentOutputSection.remove();
492
+ }
493
+ }
494
+ else if (newSection.text && newSection.text != story.sections[existingSection].text) {
495
+ // section has been updated
496
+ for (const element of elements) {
497
+ updateElementTextPreservingDisabledPassageLinks(element, newSection.text);
498
+ }
499
+ }
500
+ }
501
+
502
+ if (!story.sections[existingSection].passages) continue;
503
+
504
+ for (const existingPassage of Object.keys(story.sections[existingSection].passages)) {
505
+ const elements = getPassageContent(existingSection, existingPassage);
506
+ if (!elements.length) continue;
507
+
508
+ const newPassage = newStory.sections[existingSection]?.passages && newStory.sections[existingSection]?.passages[existingPassage];
509
+ if (!newPassage) {
510
+ // passage has been deleted
511
+ for (const element of elements) {
512
+ const parentOutputBlock = element.closest('.squiffy-output-block');
513
+ parentOutputBlock.remove();
514
+ }
515
+ }
516
+ else if (newPassage.text && newPassage.text != story.sections[existingSection].passages[existingPassage].text) {
517
+ // passage has been updated
518
+ for (const element of elements) {
519
+ updateElementTextPreservingDisabledPassageLinks(element, newPassage.text);
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ story = newStory;
526
+ currentSectionElement = outputElement.querySelector('.squiffy-output-section:last-child');
527
+ const sectionName = currentSectionElement.getAttribute('data-section');
528
+ currentSection = story.sections[sectionName];
642
529
  }
643
530
 
644
531
  outputElement = options.element;
@@ -659,12 +546,24 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
659
546
  outputElement.style.overflowY = 'auto';
660
547
  }
661
548
 
662
- initLinkHandler();
549
+ outputElement.addEventListener('click', handleClick);
550
+ outputElement.addEventListener('keypress', function (event) {
551
+ if (event.key !== "Enter") return;
552
+ handleClick(event);
553
+ });
554
+
555
+ textProcessor = new TextProcessor(get, set, story, ui, seen, processAttributes);
556
+
663
557
  begin();
664
558
 
665
559
  return {
666
560
  restart: restart,
667
561
  get: get,
668
562
  set: set,
563
+ clickLink: handleLink,
564
+ update: update,
565
+ on: (e, h) => emitter.on(e, h),
566
+ off: (e, h) => emitter.off(e, h),
567
+ once: (e, h) => emitter.once(e, h),
669
568
  };
670
569
  };