@elicdavis/node-flow 0.1.15 → 0.1.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 (43) hide show
  1. package/.mocharc.json +5 -0
  2. package/dist/index.d.mts +139 -4
  3. package/dist/index.d.ts +139 -4
  4. package/dist/index.js +3754 -3352
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +3741 -3352
  7. package/dist/index.mjs.map +1 -1
  8. package/index.html +0 -30
  9. package/package.json +4 -2
  10. package/src/graph.ts +234 -41
  11. package/src/index.ts +82 -40
  12. package/src/input.ts +22 -14
  13. package/src/markdown/entry.ts +26 -3
  14. package/src/markdown/lexicalParser.ts +22 -32
  15. package/src/markdown/syntaxParser.ts +93 -68
  16. package/src/minimap.ts +178 -0
  17. package/src/node.ts +114 -0
  18. package/src/nodes/factory.ts +9 -5
  19. package/src/nodes/publisher.ts +39 -4
  20. package/src/nodes/subsystem.ts +218 -8
  21. package/src/notes/subsystem.ts +3 -0
  22. package/src/organize.ts +276 -181
  23. package/src/port.ts +143 -7
  24. package/src/render_scheduler.ts +49 -0
  25. package/src/styles/text.ts +8 -0
  26. package/src/theme.ts +8 -0
  27. package/src/types/text.ts +8 -4
  28. package/src/utils/string.ts +7 -1
  29. package/src/widgets/color.ts +5 -6
  30. package/src/widgets/factory.ts +1 -1
  31. package/src/widgets/image.ts +2 -0
  32. package/src/widgets/number.ts +2 -2
  33. package/src/widgets/slider.ts +2 -2
  34. package/src/widgets/string.ts +2 -2
  35. package/src/widgets/toggle.ts +2 -2
  36. package/src/widgets/widget.ts +22 -0
  37. package/test/markdown.test.ts +165 -0
  38. package/test/minimap.test.ts +103 -0
  39. package/test/node_menu_filter.test.ts +119 -0
  40. package/test/organize.test.ts +156 -0
  41. package/test/port.test.ts +125 -0
  42. package/test/render_scheduler.test.ts +119 -0
  43. package/tsconfig.json +6 -2
@@ -1,12 +1,23 @@
1
+ import { requestRender } from "../render_scheduler";
1
2
  import { Theme } from "../theme";
2
3
  import { List } from "../types/list";
3
4
  import { Text } from "../types/text";
4
- import { CopyVector2, Vector2, Zero } from "../types/vector2";
5
+ import { Vector2, Zero } from "../types/vector2";
5
6
 
6
7
  export interface MarkdownEntry {
7
8
  render(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, maxWidth: number): number
8
9
  }
9
10
 
11
+ function onFontsLoaded(callback: () => void): void {
12
+ if (typeof document === "undefined" || document.fonts === undefined) {
13
+ return;
14
+ }
15
+ document.fonts.addEventListener("loadingdone", () => {
16
+ callback();
17
+ requestRender();
18
+ });
19
+ }
20
+
10
21
  export class CodeBlockEntry {
11
22
 
12
23
  #text: Text
@@ -17,6 +28,10 @@ export class CodeBlockEntry {
17
28
 
18
29
  #calculatedForWidth: number;
19
30
 
31
+ text(): Text {
32
+ return this.#text;
33
+ }
34
+
20
35
  constructor(text: Text) {
21
36
  this.#text = text;
22
37
  this.#calculatedForWidth = -1
@@ -24,7 +39,7 @@ export class CodeBlockEntry {
24
39
  this.#calculatedEntries = new List<Text>();
25
40
  this.#calculatedPositions = new List<Vector2>();
26
41
 
27
- document.fonts.addEventListener("loadingdone", (event) => {
42
+ onFontsLoaded(() => {
28
43
  this.#calculatedForWidth = -1
29
44
  });
30
45
  }
@@ -113,6 +128,10 @@ export class UnorderedListMarkdownEntry {
113
128
  this.#entries = entries;
114
129
  }
115
130
 
131
+ entries(): ReadonlyArray<BasicMarkdownEntry> {
132
+ return this.#entries;
133
+ }
134
+
116
135
  render(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, maxWidth: number): number {
117
136
  let offset = 0;
118
137
  let shift = 20;
@@ -152,6 +171,10 @@ export class BasicMarkdownEntry {
152
171
 
153
172
  #calculatedForWidth: number;
154
173
 
174
+ lines(): ReadonlyArray<Text> {
175
+ return this.#entries;
176
+ }
177
+
155
178
  constructor(lines: Array<Text>, underline: boolean, background: boolean) {
156
179
  this.#entries = lines;
157
180
  this.#underline = underline;
@@ -161,7 +184,7 @@ export class BasicMarkdownEntry {
161
184
  this.#calculatedEntries = new List<Text>();
162
185
  this.#calculatedPositions = new List<Vector2>();
163
186
 
164
- document.fonts.addEventListener("loadingdone", (event) => {
187
+ onFontsLoaded(() => {
165
188
  this.#calculatedForWidth = -1
166
189
  });
167
190
  }
@@ -37,32 +37,27 @@ export class MarkdownLexicalParser {
37
37
  return this.#body.charAt(this.#index);
38
38
  }
39
39
 
40
- #lastToken = 0;
41
-
42
- #addToken(token: MarkdownTokenType, lexeme: string): void {
43
- this.#tokens.push(new MarkdownToken(token, lexeme, this.#lastToken, this.#index));
44
- this.#lastToken = this.#index;
45
- }
40
+ #tokenStart = 0;
46
41
 
47
- #h2(): void {
48
- let char = this.#next();
49
- if (char === "#") {
50
- this.#addToken(MarkdownTokenType.H3, "###");
42
+ #addToken(token: MarkdownTokenType, lexeme: string, start?: number): void {
43
+ this.#tokens.push(new MarkdownToken(token, lexeme, start ?? this.#tokenStart, this.#index));
44
+ }
51
45
 
52
- // Move it off the character for the next reading
46
+ #header(): void {
47
+ let count = 0;
48
+ while (this.#current() === "#") {
49
+ count++;
53
50
  this.#inc();
54
- } else {
55
- this.#addToken(MarkdownTokenType.H2, "##");
56
51
  }
57
- }
58
52
 
59
- #h1(): void {
60
- let char = this.#next();
61
- if (char === "#") {
62
- this.#h2();
63
- } else {
64
- this.#addToken(MarkdownTokenType.H1, "#");
53
+ let type = MarkdownTokenType.H3;
54
+ if (count === 1) {
55
+ type = MarkdownTokenType.H1;
56
+ } else if (count === 2) {
57
+ type = MarkdownTokenType.H2;
65
58
  }
59
+
60
+ this.#addToken(type, "#".repeat(count));
66
61
  }
67
62
 
68
63
  #whiteSpace(): void {
@@ -110,34 +105,29 @@ export class MarkdownLexicalParser {
110
105
  }
111
106
 
112
107
  if (started != -1) {
113
- this.#addToken(MarkdownTokenType.Text, this.#body.substring(started, this.#index))
108
+ this.#addToken(MarkdownTokenType.Text, this.#body.substring(started, this.#index), started)
114
109
  }
115
110
  }
116
111
 
117
- // S( ) => S
118
- // S(\t) => S
119
- // S(#) => H1
120
- // H1(#) => H2
121
- // H1(!#) => S
122
- // H2(#) => H3
123
- // H2(#) => H3
124
112
  parse(): void {
125
113
  let char = this.#current();
126
114
 
127
115
  while (char !== "") {
116
+ this.#tokenStart = this.#index;
117
+
128
118
  if (char === " " || char === "\t") {
129
119
  this.#whiteSpace();
130
120
  } else if (char === "#") {
131
- this.#h1();
121
+ this.#header();
132
122
  } else if (char === "\n") {
133
- this.#addToken(MarkdownTokenType.NewLine, "\n");
134
123
  this.#inc();
124
+ this.#addToken(MarkdownTokenType.NewLine, "\n");
135
125
  } else if (char === "*") {
136
- this.#addToken(MarkdownTokenType.Star, "*");
137
126
  this.#inc();
127
+ this.#addToken(MarkdownTokenType.Star, "*");
138
128
  } else if (char === "`") {
139
- this.#addToken(MarkdownTokenType.BackTick, "`");
140
129
  this.#inc();
130
+ this.#addToken(MarkdownTokenType.BackTick, "`");
141
131
  } else {
142
132
  this.#text();
143
133
  }
@@ -31,21 +31,18 @@ export class MarkdownSyntaxParser {
31
31
 
32
32
  #next(): MarkdownToken | null {
33
33
  this.#index++
34
- if (this.#index >= this.#tokens.length - 1) {
34
+ if (this.#index >= this.#tokens.length) {
35
35
  return null
36
36
  }
37
37
  return this.#tokens[this.#index];
38
38
  }
39
39
 
40
40
  #peak(): MarkdownToken | null {
41
- if (this.#index + 1 >= this.#tokens.length - 1) {
42
- return null
43
- }
44
- return this.#tokens[this.#index + 1];
41
+ return this.#peakInto(1);
45
42
  }
46
43
 
47
44
  #peakInto(amount: number): MarkdownToken | null {
48
- if (this.#index + amount >= this.#tokens.length - 1) {
45
+ if (this.#index + amount >= this.#tokens.length) {
49
46
  return null
50
47
  }
51
48
  return this.#tokens[this.#index + amount];
@@ -53,82 +50,107 @@ export class MarkdownSyntaxParser {
53
50
 
54
51
  #emphasis(): Array<Text> {
55
52
 
56
- // Cases to consider
57
- // =================
58
- // 1. *a*: <i>a</i>
59
- // 2. **a**: <b>a</b>
60
- // 3. **a*: *<i>a</i> <----- fuck this one
61
- // 4. ***a**: *<b>a</b> <----- fuck this one
62
- // 5. *a**: <i>a</i>*
63
- // 6. **a***: <b>a</b>*
64
- // 7. * a *: * a *
65
- // 8. ** a **: ** a **
66
- // 9. **a **: **a **
67
- // 10. **a f**: <b>a f<b>
68
- // 11. * Test: <ul>Test</ul>
69
-
70
- let token = this.#next();
71
- if (token?.type() === MarkdownTokenType.Space) {
53
+ // Count the stars either side of the content and match as many as
54
+ // both ends can supply; the rest stay on the line as characters.
55
+ //
56
+ // *a* -> <i>a</i> **a* -> *<i>a</i>
57
+ // **a** -> <b>a</b> *a** -> <i>a</i>*
58
+ // **a f** -> <b>a f</b> *a -> *a
59
+ // ** a ** -> ** a ** **a ** -> **a **
60
+
61
+ // A star with a space after it opens nothing, so leave the space
62
+ // where it is rather than consuming it - the caller still has to
63
+ // read it as part of the line.
64
+ if (this.#peak()?.type() === MarkdownTokenType.Space) {
72
65
  return [new Text("*")];
73
66
  }
74
67
 
75
- let bold = false;
76
- // Read off all leading stars
77
- while (token !== null && token.type() === MarkdownTokenType.Star) {
78
- bold = true;
79
- token = this.#next();
80
- }
81
-
82
- let textContent = "";
68
+ const openers = this.#runOfStars();
83
69
 
84
- let boldClosed = false;
85
- let startingClose = false;
86
- let validClose = false;
87
- while (token !== null && token.type() !== MarkdownTokenType.NewLine) {
70
+ let content = "";
71
+ let closers = 0;
72
+ while (true) {
73
+ const token = this.#current();
74
+ if (token === null || token.type() === MarkdownTokenType.NewLine) {
75
+ break;
76
+ }
88
77
  if (token.type() === MarkdownTokenType.Star) {
78
+ closers = this.#runOfStars();
79
+ break;
80
+ }
81
+ content += token.lexeme();
82
+ this.#inc();
83
+ }
89
84
 
90
- // This is the second star we've seen in a row. We're done!
91
- if (startingClose) {
92
- boldClosed = true;
93
- break;
94
- }
85
+ // Leave the reader on the last token taken, since the caller steps
86
+ // forward once more before looking at anything.
87
+ this.#index--;
95
88
 
96
- // If we're not bold, we just need a single star to close this off.
97
- startingClose = true;
98
- if (bold === false) {
99
- break;
100
- }
101
- }
89
+ // A run of stars only emphasises what it hugs. With a space against
90
+ // the inside of either end, or nothing between them at all, the
91
+ // stars are just stars.
92
+ const hugsContent = content !== ""
93
+ && content.trimStart() === content
94
+ && content.trimEnd() === content;
102
95
 
103
- if (startingClose && token.type() !== MarkdownTokenType.Star) {
104
- break;
105
- }
96
+ const matched = hugsContent ? Math.min(openers, closers) : 0;
97
+ if (matched === 0) {
98
+ return [new Text("*".repeat(openers) + content + "*".repeat(closers))];
99
+ }
106
100
 
107
- // Add to string content...
108
- if (token.type() === MarkdownTokenType.Text) {
109
- textContent += token.lexeme();
110
- validClose = true;
111
- }
101
+ const style: TextStyleConfig = {};
102
+ if (matched >= 2) {
103
+ style.weight = FontWeight.Bold;
104
+ } else {
105
+ style.style = FontStyle.Italic;
106
+ }
112
107
 
113
- if (token.type() === MarkdownTokenType.Space) {
114
- textContent += token.lexeme();
115
- validClose = false;
116
- }
108
+ // Stars past the pair that matched were never part of the emphasis,
109
+ // so they stay on the line as the characters they are.
110
+ const runs = new Array<Text>();
111
+ if (openers > matched) {
112
+ runs.push(new Text("*".repeat(openers - matched)));
113
+ }
114
+ runs.push(new Text(content, style));
115
+ if (closers > matched) {
116
+ runs.push(new Text("*".repeat(closers - matched)));
117
+ }
118
+ return runs;
119
+ }
117
120
 
118
- token = this.#next();
121
+ /** Consumes a run of stars, leaving the reader on the token after it. */
122
+ #runOfStars(): number {
123
+ let count = 0;
124
+ while (this.#current()?.type() === MarkdownTokenType.Star) {
125
+ count++;
126
+ this.#inc();
119
127
  }
128
+ return count;
129
+ }
120
130
 
121
- const style: TextStyleConfig = {}
131
+ /**
132
+ * Reads `code` up to the closing backtick on the same line. An opening
133
+ * backtick with no partner is only a backtick.
134
+ */
135
+ #inlineCode(): Array<Text> {
136
+ const opened = this.#index;
122
137
 
123
- if (validClose && startingClose) {
124
- if (boldClosed) {
125
- style.weight = FontWeight.Bold;
126
- } else {
127
- style.style = FontStyle.Italic;
138
+ this.#inc();
139
+ let content = "";
140
+ while (true) {
141
+ const token = this.#current();
142
+ if (token === null || token.type() === MarkdownTokenType.NewLine) {
143
+ this.#index = opened;
144
+ return [new Text("`")];
128
145
  }
146
+ if (token.type() === MarkdownTokenType.BackTick) {
147
+ break;
148
+ }
149
+ content += token.lexeme();
150
+ this.#inc();
129
151
  }
130
152
 
131
- return [new Text(textContent, style)]
153
+ return [new Text(content, { font: "monospace" })];
132
154
  }
133
155
 
134
156
  #text(): Array<Text> {
@@ -153,13 +175,16 @@ export class MarkdownSyntaxParser {
153
175
  break;
154
176
 
155
177
  case MarkdownTokenType.Star:
178
+ case MarkdownTokenType.BackTick:
156
179
  if (textContent !== "") {
157
180
  contents.push(new Text(textContent))
158
181
  textContent = "";
159
182
  }
160
- let starText = this.#emphasis()
161
- for (let i = 0; i < starText.length; i++) {
162
- contents.push(starText[i]);
183
+ const runs = token.type() === MarkdownTokenType.Star
184
+ ? this.#emphasis()
185
+ : this.#inlineCode();
186
+ for (let i = 0; i < runs.length; i++) {
187
+ contents.push(runs[i]);
163
188
  }
164
189
  break;
165
190
  }
package/src/minimap.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { Camera } from "./camera";
2
+ import { FlowNode } from "./node";
3
+ import { Theme } from "./theme";
4
+ import { Box, InBox } from "./types/box";
5
+ import { Vector2 } from "./types/vector2";
6
+
7
+ export interface MinimapConfig {
8
+ enabled?: boolean;
9
+ width?: number;
10
+ height?: number;
11
+ margin?: number;
12
+
13
+ /** Overrides Theme.Minimap.BackgroundColor for this minimap alone. */
14
+ backgroundColor?: string;
15
+
16
+ /** Overrides Theme.Minimap.BorderColor for this minimap alone. */
17
+ borderColor?: string;
18
+
19
+ /** Overrides Theme.Minimap.NodeColor for this minimap alone. */
20
+ nodeColor?: string;
21
+
22
+ /** Overrides Theme.Minimap.ViewportColor for this minimap alone. */
23
+ viewportColor?: string;
24
+ }
25
+
26
+ export class Minimap {
27
+
28
+ #enabled: boolean;
29
+
30
+ #width: number;
31
+
32
+ #height: number;
33
+
34
+ #margin: number;
35
+
36
+ #backgroundColor?: string;
37
+
38
+ #borderColor?: string;
39
+
40
+ #nodeColor?: string;
41
+
42
+ #viewportColor?: string;
43
+
44
+ #box: Box = { Position: { x: 0, y: 0 }, Size: { x: 0, y: 0 } };
45
+
46
+ #scale: number = 1;
47
+
48
+ #originX: number = 0;
49
+
50
+ #originY: number = 0;
51
+
52
+ // The padding the last frame was laid out with, so a click maps back
53
+ // through the same geometry it was drawn with.
54
+ #padding: number = Theme.Minimap.Padding;
55
+
56
+ #drawn: boolean = false;
57
+
58
+ constructor(config?: MinimapConfig) {
59
+ this.#enabled = config?.enabled === true;
60
+ this.#width = config?.width === undefined ? 200 : config.width;
61
+ this.#height = config?.height === undefined ? 150 : config.height;
62
+ this.#margin = config?.margin === undefined ? 16 : config.margin;
63
+ this.#backgroundColor = config?.backgroundColor;
64
+ this.#borderColor = config?.borderColor;
65
+ this.#nodeColor = config?.nodeColor;
66
+ this.#viewportColor = config?.viewportColor;
67
+ }
68
+
69
+ enabled(): boolean {
70
+ return this.#enabled;
71
+ }
72
+
73
+ setEnabled(enabled: boolean): void {
74
+ this.#enabled = enabled;
75
+ this.#drawn = false;
76
+ }
77
+
78
+ contains(position: Vector2): boolean {
79
+ return this.#enabled && this.#drawn && InBox(this.#box, position);
80
+ }
81
+
82
+ graphPositionOf(position: Vector2, out: Vector2): void {
83
+ out.x = (position.x - this.#box.Position.x - this.#padding) / this.#scale + this.#originX;
84
+ out.y = (position.y - this.#box.Position.y - this.#padding) / this.#scale + this.#originY;
85
+ }
86
+
87
+ render(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, camera: Camera, nodes: Array<FlowNode>): void {
88
+ if (!this.#enabled) {
89
+ this.#drawn = false;
90
+ return;
91
+ }
92
+
93
+ // Bounds are taken with a fresh camera so they come back in graph
94
+ // space rather than wherever the real camera happens to be looking.
95
+ const flat = new Camera();
96
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
97
+
98
+ const boxes: Array<Box> = [];
99
+ for (let i = 0; i < nodes.length; i++) {
100
+ const bounds = nodes[i].calculateBounds(ctx, flat);
101
+ boxes.push(bounds);
102
+ minX = Math.min(minX, bounds.Position.x);
103
+ minY = Math.min(minY, bounds.Position.y);
104
+ maxX = Math.max(maxX, bounds.Position.x + bounds.Size.x);
105
+ maxY = Math.max(maxY, bounds.Position.y + bounds.Size.y);
106
+ }
107
+
108
+ // What the camera can currently see, so the graph never scales out
109
+ // from under the viewport box.
110
+ const topLeft: Vector2 = { x: 0, y: 0 };
111
+ const bottomRight: Vector2 = { x: 0, y: 0 };
112
+ camera.screenSpaceToGraphSpace({ x: 0, y: 0 }, topLeft);
113
+ camera.screenSpaceToGraphSpace({ x: canvas.width, y: canvas.height }, bottomRight);
114
+
115
+ minX = Math.min(minX, topLeft.x);
116
+ minY = Math.min(minY, topLeft.y);
117
+ maxX = Math.max(maxX, bottomRight.x);
118
+ maxY = Math.max(maxY, bottomRight.y);
119
+
120
+ if (!isFinite(minX) || maxX <= minX || maxY <= minY) {
121
+ this.#drawn = false;
122
+ return;
123
+ }
124
+
125
+ this.#box.Size.x = this.#width;
126
+ this.#box.Size.y = this.#height;
127
+ this.#box.Position.x = canvas.width - this.#width - this.#margin;
128
+ this.#box.Position.y = canvas.height - this.#height - this.#margin;
129
+
130
+ this.#padding = Theme.Minimap.Padding;
131
+
132
+ const usableWidth = this.#width - (this.#padding * 2);
133
+ const usableHeight = this.#height - (this.#padding * 2);
134
+ this.#scale = Math.min(usableWidth / (maxX - minX), usableHeight / (maxY - minY));
135
+ this.#originX = minX;
136
+ this.#originY = minY;
137
+
138
+ const left = this.#box.Position.x + this.#padding;
139
+ const top = this.#box.Position.y + this.#padding;
140
+ const toMapX = (x: number) => left + ((x - minX) * this.#scale);
141
+ const toMapY = (y: number) => top + ((y - minY) * this.#scale);
142
+
143
+ ctx.fillStyle = this.#backgroundColor ?? Theme.Minimap.BackgroundColor;
144
+ ctx.strokeStyle = this.#borderColor ?? Theme.Minimap.BorderColor;
145
+ ctx.lineWidth = 1;
146
+ ctx.beginPath();
147
+ ctx.roundRect(
148
+ this.#box.Position.x,
149
+ this.#box.Position.y,
150
+ this.#width,
151
+ this.#height,
152
+ Theme.Minimap.BorderRadius,
153
+ );
154
+ ctx.fill();
155
+ ctx.stroke();
156
+
157
+ ctx.fillStyle = this.#nodeColor ?? Theme.Minimap.NodeColor;
158
+ for (let i = 0; i < boxes.length; i++) {
159
+ const b = boxes[i];
160
+ ctx.fillRect(
161
+ toMapX(b.Position.x),
162
+ toMapY(b.Position.y),
163
+ Math.max(1, b.Size.x * this.#scale),
164
+ Math.max(1, b.Size.y * this.#scale),
165
+ );
166
+ }
167
+
168
+ ctx.fillStyle = this.#viewportColor ?? Theme.Minimap.ViewportColor;
169
+ ctx.fillRect(
170
+ toMapX(topLeft.x),
171
+ toMapY(topLeft.y),
172
+ Math.max(1, (bottomRight.x - topLeft.x) * this.#scale),
173
+ Math.max(1, (bottomRight.y - topLeft.y) * this.#scale),
174
+ );
175
+
176
+ this.#drawn = true;
177
+ }
178
+ }