@elicdavis/node-flow 0.1.19 → 0.2.0

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 (55) hide show
  1. package/dist/index.d.mts +386 -227
  2. package/dist/index.d.ts +386 -227
  3. package/dist/index.js +1884 -1076
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1867 -1075
  6. package/dist/index.mjs.map +1 -1
  7. package/index.html +1134 -329
  8. package/package.json +1 -1
  9. package/src/connection.ts +30 -5
  10. package/src/contextMenu.ts +66 -34
  11. package/src/graph.ts +12 -10
  12. package/src/index.ts +26 -12
  13. package/src/markdown/entry.ts +11 -15
  14. package/src/markdown/syntaxParser.ts +2 -1
  15. package/src/node.ts +162 -111
  16. package/src/nodes/subsystem.ts +123 -29
  17. package/src/notes/note.ts +9 -1
  18. package/src/organize.ts +540 -111
  19. package/src/popup.ts +4 -2
  20. package/src/port.ts +83 -6
  21. package/src/styles/bevel.ts +31 -0
  22. package/src/styles/cursor.ts +1 -0
  23. package/src/styles/text.ts +4 -4
  24. package/src/theme.ts +179 -92
  25. package/src/types/text.ts +5 -1
  26. package/src/utils/color.ts +1 -1
  27. package/src/widgets/button.ts +52 -107
  28. package/src/widgets/color.ts +91 -150
  29. package/src/widgets/factory.ts +89 -46
  30. package/src/widgets/field.ts +99 -0
  31. package/src/widgets/image.ts +107 -165
  32. package/src/widgets/inplace.ts +84 -0
  33. package/src/widgets/number.ts +122 -154
  34. package/src/widgets/row.ts +138 -0
  35. package/src/widgets/slider.ts +101 -216
  36. package/src/widgets/string.ts +39 -130
  37. package/src/widgets/text.ts +81 -92
  38. package/src/widgets/toggle.ts +62 -207
  39. package/src/widgets/value.ts +127 -0
  40. package/src/widgets/widget.ts +180 -34
  41. package/test/context_menu.test.ts +82 -0
  42. package/test/cursor.test.ts +17 -0
  43. package/test/hidden_port.test.ts +125 -0
  44. package/test/hover_after_removal.test.ts +72 -0
  45. package/test/markdown.test.ts +2 -2
  46. package/test/markdown_code_block.test.ts +58 -0
  47. package/test/multi_input.test.ts +124 -0
  48. package/test/organize.test.ts +117 -1
  49. package/test/property_binding.test.ts +49 -0
  50. package/test/support.ts +175 -0
  51. package/test/theme.test.ts +86 -0
  52. package/test/widget_click.test.ts +111 -97
  53. package/test/widget_construction.test.ts +5 -4
  54. package/test/widgets.test.ts +333 -0
  55. package/test/wire_follows_camera.test.ts +57 -0
@@ -1,34 +1,180 @@
1
- import { Box } from '../types/box';
2
- import { Vector2 } from '../types/vector2';
3
-
4
- export const width = 150;
5
- export const height = 25;
6
-
7
- export interface Widget {
8
- Size(): Vector2
9
- Draw(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, mousePosition: Vector2 | undefined): Box
10
- ClickStart(): void
11
- ClickEnd(): void
12
- }
13
-
14
- interface PropertyReader {
15
- getProperty(name: string): any;
16
- }
17
-
18
- export function startingWidgetValue<T>(
19
- node: PropertyReader,
20
- property: string | undefined,
21
- configured: T | undefined,
22
- fallback: T,
23
- ): T {
24
- if (configured !== undefined) {
25
- return configured;
26
- }
27
- if (property !== undefined && property !== null) {
28
- const existing = node.getProperty(property);
29
- if (existing !== undefined && existing !== null) {
30
- return existing as T;
31
- }
32
- }
33
- return fallback;
34
- }
1
+ import { Box, InBox } from '../types/box';
2
+ import { Vector2 } from '../types/vector2';
3
+ import { CursorStyle } from '../styles/cursor';
4
+ import { requestRender } from '../render_scheduler';
5
+
6
+ export const width = 150;
7
+ export const height = 25;
8
+
9
+ /**
10
+ * Where the pointer is, in canvas pixels, and whether it is over the widget.
11
+ * A press that starts on a widget keeps reporting to it until it is let go,
12
+ * wherever the pointer wanders.
13
+ */
14
+ export interface WidgetPointer {
15
+ position: Vector2;
16
+ inside: boolean;
17
+ }
18
+
19
+ export interface Widget {
20
+ /**
21
+ * The smallest room the widget needs, in graph units. The node may hand
22
+ * it a wider box.
23
+ */
24
+ Size(): Vector2;
25
+
26
+ Draw(ctx: CanvasRenderingContext2D, box: Box, scale: number, mousePosition: Vector2 | undefined): void;
27
+
28
+ PointerDown?(pointer: WidgetPointer): void;
29
+ PointerMove?(pointer: WidgetPointer): void;
30
+ PointerUp?(pointer: WidgetPointer): void;
31
+
32
+ Cursor?(): CursorStyle;
33
+ Hidden?(): boolean;
34
+ Disabled?(): boolean;
35
+ }
36
+
37
+ export abstract class WidgetBase implements Widget {
38
+
39
+ #hidden = false;
40
+
41
+ #disabled = false;
42
+
43
+ #hovered = false;
44
+
45
+ #pressed = false;
46
+
47
+ #box: Box = { Position: { x: 0, y: 0 }, Size: { x: 0, y: 0 } };
48
+
49
+ #canvas: HTMLCanvasElement | null = null;
50
+
51
+ #scale = 1;
52
+
53
+ abstract Size(): Vector2;
54
+
55
+ protected abstract render(ctx: CanvasRenderingContext2D, box: Box, scale: number): void;
56
+
57
+ Draw(ctx: CanvasRenderingContext2D, box: Box, scale: number, mousePosition: Vector2 | undefined): void {
58
+ this.#box = { Position: { x: box.Position.x, y: box.Position.y }, Size: { x: box.Size.x, y: box.Size.y } };
59
+ this.#canvas = ctx.canvas ?? null;
60
+ this.#scale = scale;
61
+ this.#hovered = !this.#disabled && mousePosition !== undefined && InBox(box, mousePosition);
62
+
63
+ ctx.save();
64
+ if (this.#disabled) {
65
+ ctx.globalAlpha *= 0.4;
66
+ }
67
+ this.render(ctx, box, scale);
68
+ ctx.restore();
69
+ }
70
+
71
+ PointerDown(pointer: WidgetPointer): void {
72
+ if (this.#disabled) {
73
+ return;
74
+ }
75
+ this.#pressed = true;
76
+ this.onPointerDown(pointer);
77
+ requestRender();
78
+ }
79
+
80
+ PointerMove(pointer: WidgetPointer): void {
81
+ if (!this.#pressed) {
82
+ return;
83
+ }
84
+ this.onPointerMove(pointer);
85
+ requestRender();
86
+ }
87
+
88
+ PointerUp(pointer: WidgetPointer): void {
89
+ if (!this.#pressed) {
90
+ return;
91
+ }
92
+ this.#pressed = false;
93
+ this.onPointerUp(pointer);
94
+ if (pointer.inside) {
95
+ this.onClick(pointer);
96
+ }
97
+ requestRender();
98
+ }
99
+
100
+ Cursor(): CursorStyle {
101
+ return CursorStyle.Pointer;
102
+ }
103
+
104
+ Hidden(): boolean {
105
+ return this.#hidden;
106
+ }
107
+
108
+ setHidden(hidden: boolean): void {
109
+ this.#hidden = hidden;
110
+ requestRender();
111
+ }
112
+
113
+ Disabled(): boolean {
114
+ return this.#disabled;
115
+ }
116
+
117
+ setDisabled(disabled: boolean): void {
118
+ this.#disabled = disabled;
119
+ if (disabled) {
120
+ this.#pressed = false;
121
+ }
122
+ requestRender();
123
+ }
124
+
125
+ protected hovered(): boolean {
126
+ return this.#hovered;
127
+ }
128
+
129
+ protected pressed(): boolean {
130
+ return this.#pressed;
131
+ }
132
+
133
+ /**
134
+ * Where the widget was last drawn, in canvas pixels.
135
+ */
136
+ protected box(): Box {
137
+ return this.#box;
138
+ }
139
+
140
+ protected canvas(): HTMLCanvasElement | null {
141
+ return this.#canvas;
142
+ }
143
+
144
+ protected scale(): number {
145
+ return this.#scale;
146
+ }
147
+
148
+ protected onPointerDown(pointer: WidgetPointer): void { }
149
+
150
+ protected onPointerMove(pointer: WidgetPointer): void { }
151
+
152
+ protected onPointerUp(pointer: WidgetPointer): void { }
153
+
154
+ /**
155
+ * A press and release that both landed on the widget.
156
+ */
157
+ protected onClick(pointer: WidgetPointer): void { }
158
+ }
159
+
160
+ interface PropertyReader {
161
+ getProperty(name: string): any;
162
+ }
163
+
164
+ export function startingWidgetValue<T>(
165
+ node: PropertyReader,
166
+ property: string | undefined,
167
+ configured: T | undefined,
168
+ fallback: T,
169
+ ): T {
170
+ if (configured !== undefined) {
171
+ return configured;
172
+ }
173
+ if (property !== undefined && property !== null) {
174
+ const existing = node.getProperty(property);
175
+ if (existing !== undefined && existing !== null) {
176
+ return existing as T;
177
+ }
178
+ }
179
+ return fallback;
180
+ }
@@ -0,0 +1,82 @@
1
+ import { strict as assert } from 'assert';
2
+ import { ContextMenu, ContextMenuConfig } from '../src/contextMenu';
3
+ import { setTheme } from '../src/theme';
4
+
5
+ interface Frame {
6
+ texts: Array<{ text: string; x: number; y: number; fillStyle: string }>;
7
+ }
8
+
9
+ function ctxFor(width: number, frame: Frame): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { clientWidth: width, clientHeight: 600 },
12
+ font: '', fillStyle: '', strokeStyle: '', lineWidth: 1,
13
+ textAlign: 'left', textBaseline: 'middle', shadowColor: '', shadowBlur: 0,
14
+ measureText: (text: string) => ({ width: text.length * 7, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
15
+ beginPath() { }, roundRect() { }, fill() { }, stroke() { }, lineTo() { }, moveTo() { },
16
+ fillText(text: string, x: number, y: number) { frame.texts.push({ text, x, y, fillStyle: this.fillStyle }); },
17
+ };
18
+ return ctx as CanvasRenderingContext2D;
19
+ }
20
+
21
+ function render(menu: ContextMenu, width: number, at: { x: number; y: number }, mouse: { x: number; y: number }) {
22
+ const frame: Frame = { texts: [] };
23
+ const over = menu.render(ctxFor(width, frame), at, 1, mouse, true);
24
+ return { over, frame, find: (text: string) => frame.texts.find((t) => t.text === text) };
25
+ }
26
+
27
+ const ROW = 30 * 1.25;
28
+
29
+ const config: ContextMenuConfig = {
30
+ subMenus: [
31
+ { name: 'More', items: [{ name: 'Alpha' }, { name: 'Beta' }] },
32
+ { name: 'Other', items: [{ name: 'Gamma' }] },
33
+ ],
34
+ };
35
+
36
+ describe('a context menu', () => {
37
+ it('opens a submenu on the far side of its parent when the right is out of room', () => {
38
+ const menu = new ContextMenu(config);
39
+ const at = { x: 250, y: 100 };
40
+ const onMore = { x: at.x + 10, y: at.y + ROW / 2 };
41
+
42
+ render(menu, 400, at, onMore);
43
+ const { find } = render(menu, 400, at, onMore);
44
+
45
+ const alpha = find('Alpha');
46
+ assert.ok(alpha, 'the submenu is open');
47
+ assert.ok(alpha.x < at.x, `the submenu sits left of its parent, not on top of it (at ${alpha.x})`);
48
+ });
49
+
50
+ it('lights only the row under the pointer in the frame it moves there', () => {
51
+ setTheme({ ContextMenu: { FontColor: '#000000', HighlightFontColor: '#ffffff' } });
52
+ const menu = new ContextMenu(config);
53
+ const at = { x: 10, y: 100 };
54
+
55
+ render(menu, 800, at, { x: at.x + 5, y: at.y + ROW / 2 });
56
+ const moved = render(menu, 800, at, { x: at.x + 5, y: at.y + ROW * 1.5 });
57
+ setTheme();
58
+
59
+ assert.equal(moved.find('More')?.fillStyle, '#000000');
60
+ assert.equal(moved.find('Other')?.fillStyle, '#ffffff');
61
+ assert.ok(moved.find('Gamma'), 'the newly hovered row opens its submenu in the same frame');
62
+ assert.ok(!moved.find('Alpha'), 'the old submenu is gone in the same frame');
63
+ });
64
+
65
+ it('keeps listening to an open submenu that has to overlap its parent', () => {
66
+ const menu = new ContextMenu(config);
67
+ const at = { x: 10, y: 100 };
68
+
69
+ render(menu, 120, at, { x: at.x + 5, y: at.y + ROW / 2 });
70
+ const opened = render(menu, 120, at, { x: at.x + 5, y: at.y + ROW / 2 });
71
+ const beta = opened.find('Beta');
72
+ assert.ok(beta, 'the submenu is open');
73
+
74
+ const onBeta = { x: beta.x + 5, y: beta.y };
75
+ render(menu, 120, at, onBeta);
76
+ const hovering = render(menu, 120, at, onBeta);
77
+
78
+ assert.equal(hovering.over?.text, 'Beta');
79
+ assert.ok(hovering.find('Alpha'), 'the submenu under the pointer stays open');
80
+ assert.ok(!hovering.find('Gamma'), 'the parent row underneath does not open its own submenu');
81
+ });
82
+ });
@@ -0,0 +1,17 @@
1
+ import { strict as assert } from 'assert';
2
+ import { GraphView } from '../src/graph';
3
+ import { NoteSubsystem } from '../src/notes/subsystem';
4
+ import { CursorStyle } from '../src/styles/cursor';
5
+ import { SliderWidget } from '../src/widgets/slider';
6
+ import { center, widgetScene } from './support';
7
+
8
+ describe('the cursor', () => {
9
+ it('shows the hovered widget\'s cursor once drawn through the graph', () => {
10
+ const { graph, camera, ctx, widgetBox } = widgetScene((node) => new SliderWidget(node, { value: 0.5 }));
11
+ const view = new GraphView([new NoteSubsystem(), graph]);
12
+
13
+ const results = view.render(ctx, camera, center(widgetBox()));
14
+
15
+ assert.equal(results?.cursorStyle, CursorStyle.EWResize);
16
+ });
17
+ });
@@ -0,0 +1,125 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NodeSubsystem } from '../src/nodes/subsystem';
3
+ import { PassSubsystem } from '../src/pass/subsystem';
4
+ import { FlowNode } from '../src/node';
5
+ import { Camera } from '../src/camera';
6
+ import { Box } from '../src/types/box';
7
+ import { Vector2 } from '../src/types/vector2';
8
+
9
+ function stubCtx(): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { width: 800, height: 600 },
12
+ lineWidth: 1,
13
+ font: '',
14
+ textAlign: 'left',
15
+ textBaseline: 'top',
16
+ fillStyle: '',
17
+ strokeStyle: '',
18
+ globalAlpha: 1,
19
+ measureText: () => ({ width: 40, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
20
+ save: () => { }, restore: () => { }, translate: () => { }, scale: () => { },
21
+ beginPath: () => { }, closePath: () => { }, roundRect: () => { }, rect: () => { },
22
+ moveTo: () => { }, lineTo: () => { }, arc: () => { }, ellipse: () => { },
23
+ bezierCurveTo: () => { }, quadraticCurveTo: () => { },
24
+ fill: () => { }, stroke: () => { }, fillText: () => { }, strokeText: () => { },
25
+ clip: () => { }, drawImage: () => { }, setLineDash: () => { },
26
+ createLinearGradient: () => ({ addColorStop: () => { } }),
27
+ };
28
+ return ctx as CanvasRenderingContext2D;
29
+ }
30
+
31
+ function center(box: Box): Vector2 {
32
+ return { x: box.Position.x + box.Size.x / 2, y: box.Position.y + box.Size.y / 2 };
33
+ }
34
+
35
+ function scene(hidden: boolean) {
36
+ const graph = new NodeSubsystem(new PassSubsystem());
37
+ const node = new FlowNode({
38
+ title: 'material',
39
+ position: { x: 100, y: 100 },
40
+ inputs: [
41
+ { name: 'Color', type: 'color' },
42
+ { name: 'Sheen', type: 'sheen', hidden },
43
+ { name: 'Roughness', type: 'float64' },
44
+ ],
45
+ outputs: [{ name: 'Out', type: 'material' }],
46
+ });
47
+ graph.addNode(node);
48
+ return { graph, node, camera: new Camera(), ctx: stubCtx() };
49
+ }
50
+
51
+ describe('a hidden port', () => {
52
+ it('takes no room on its node', () => {
53
+ const shown = scene(false);
54
+ const hidden = scene(true);
55
+
56
+ const shownHeight = shown.node.calculateBounds(shown.ctx, shown.camera).Size.y;
57
+ const hiddenHeight = hidden.node.calculateBounds(hidden.ctx, hidden.camera).Size.y;
58
+
59
+ assert.ok(hiddenHeight < shownHeight);
60
+ });
61
+
62
+ it('cannot be hovered where it would have been drawn', () => {
63
+ const { graph, node, camera, ctx } = scene(false);
64
+ graph.render(ctx, camera, undefined);
65
+ const where = center(node.inputPortPosition(1));
66
+
67
+ node.inputPort(1).setHidden(true);
68
+ graph.render(ctx, camera, undefined);
69
+
70
+ assert.notEqual(node.inBounds(ctx, camera, where).Port, node.inputPort(1));
71
+ });
72
+
73
+ it('keeps the ports after it lined up with their indices', () => {
74
+ const { graph, node, camera, ctx } = scene(true);
75
+ graph.render(ctx, camera, undefined);
76
+
77
+ const roughness = center(node.inputPortPosition(2));
78
+ assert.equal(node.inBounds(ctx, camera, roughness).Port, node.inputPort(2));
79
+ });
80
+
81
+ it('asks for a fresh layout when it is shown or hidden', () => {
82
+ const { graph, node, camera, ctx } = scene(true);
83
+ graph.render(ctx, camera, undefined);
84
+ assert.ok(node.hasBeenLaidOut());
85
+
86
+ node.inputPort(1).setHidden(false);
87
+
88
+ assert.ok(!node.hasBeenLaidOut());
89
+ });
90
+
91
+ it('asks for a fresh layout when a wire makes it show', () => {
92
+ const { graph, node, camera, ctx } = scene(true);
93
+ const source = new FlowNode({ title: 'sheen', outputs: [{ name: 'Out', type: 'sheen' }] });
94
+ graph.addNode(source);
95
+ graph.render(ctx, camera, undefined);
96
+
97
+ graph.connectNodes(source, 0, node, 1);
98
+
99
+ assert.ok(!node.hasBeenLaidOut());
100
+ });
101
+
102
+ it('moves the ports below it for a node drawn off screen', () => {
103
+ const { graph, node, camera, ctx } = scene(true);
104
+ const offset = () => node.inputPortPosition(2).Position.y - node.calculateBounds(ctx, camera).Position.y;
105
+ graph.render(ctx, camera, undefined);
106
+ const before = offset();
107
+
108
+ camera.position.x = -50000;
109
+ graph.render(ctx, camera, undefined);
110
+ node.inputPort(1).setHidden(false);
111
+ graph.render(ctx, camera, undefined);
112
+
113
+ assert.ok(offset() > before);
114
+ });
115
+
116
+ it('shows while something is connected to it', () => {
117
+ const { graph, node } = scene(true);
118
+ const source = new FlowNode({ title: 'sheen', outputs: [{ name: 'Out', type: 'sheen' }] });
119
+ graph.addNode(source);
120
+
121
+ graph.connectNodes(source, 0, node, 1);
122
+
123
+ assert.equal(node.inputPort(1).hidden(), false);
124
+ });
125
+ });
@@ -0,0 +1,72 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NodeSubsystem } from '../src/nodes/subsystem';
3
+ import { PassSubsystem } from '../src/pass/subsystem';
4
+ import { FlowNode } from '../src/node';
5
+ import { Camera } from '../src/camera';
6
+ import { ContextMenuConfig } from '../src/contextMenu';
7
+ import { Vector2 } from '../src/types/vector2';
8
+
9
+ function stubCtx(): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { width: 800, height: 600 },
12
+ lineWidth: 1,
13
+ font: '',
14
+ textAlign: 'left',
15
+ textBaseline: 'top',
16
+ fillStyle: '',
17
+ strokeStyle: '',
18
+ globalAlpha: 1,
19
+ measureText: () => ({ width: 40 }),
20
+ save: () => { }, restore: () => { }, translate: () => { }, scale: () => { },
21
+ beginPath: () => { }, closePath: () => { }, roundRect: () => { }, rect: () => { },
22
+ moveTo: () => { }, lineTo: () => { }, arc: () => { }, ellipse: () => { },
23
+ bezierCurveTo: () => { }, quadraticCurveTo: () => { },
24
+ fill: () => { }, stroke: () => { }, fillText: () => { }, strokeText: () => { },
25
+ clip: () => { }, drawImage: () => { }, setLineDash: () => { },
26
+ createLinearGradient: () => ({ addColorStop: () => { } }),
27
+ };
28
+ return ctx as CanvasRenderingContext2D;
29
+ }
30
+
31
+ function hoverOver(graph: NodeSubsystem, node: FlowNode, ctx: CanvasRenderingContext2D, camera: Camera): Vector2 {
32
+ graph.render(ctx, camera, undefined);
33
+ const bounds = node.calculateBounds(ctx, camera);
34
+ const over = { x: bounds.Position.x + bounds.Size.x / 2, y: bounds.Position.y + bounds.Size.y / 2 };
35
+ graph.render(ctx, camera, over);
36
+ return over;
37
+ }
38
+
39
+ function deleteNodeItem(menu: ContextMenuConfig | null) {
40
+ return menu?.subMenus?.find((m) => m.name === 'Delete')?.items?.find((i) => i.name === 'Node');
41
+ }
42
+
43
+ describe('the hovered node going away before the next render', () => {
44
+ it('opens the context menu without it', () => {
45
+ const graph = new NodeSubsystem(new PassSubsystem());
46
+ const node = new FlowNode({ title: 'gone', position: { x: 100, y: 100 } });
47
+ graph.addNode(node);
48
+ const ctx = stubCtx();
49
+ const camera = new Camera();
50
+ const over = hoverOver(graph, node, ctx, camera);
51
+
52
+ graph.removeNode(node);
53
+
54
+ assert.equal(deleteNodeItem(graph.openContextMenu(ctx, over)), undefined);
55
+ });
56
+
57
+ it('keeps pointing at the same node when an earlier one is removed', () => {
58
+ const graph = new NodeSubsystem(new PassSubsystem());
59
+ const earlier = new FlowNode({ title: 'earlier', position: { x: 100, y: 100 } });
60
+ const hovered = new FlowNode({ title: 'hovered', position: { x: 400, y: 100 } });
61
+ graph.addNode(earlier);
62
+ graph.addNode(hovered);
63
+ const ctx = stubCtx();
64
+ const camera = new Camera();
65
+ const over = hoverOver(graph, hovered, ctx, camera);
66
+
67
+ graph.removeNode(earlier);
68
+ deleteNodeItem(graph.openContextMenu(ctx, over))?.callback?.();
69
+
70
+ assert.deepEqual(graph.getNodes(), []);
71
+ });
72
+ });
@@ -149,12 +149,12 @@ describe('markdown: code', () => {
149
149
  const entries = BuildMarkdown('```\nlet a = *b*;\n```');
150
150
  assert.strictEqual(entries.length, 1);
151
151
  assert.ok(entries[0] instanceof CodeBlockEntry, `got ${entries[0].constructor.name}`);
152
- assert.strictEqual(contentOf(entries[0]), 'let a = *b*;\n');
152
+ assert.strictEqual(contentOf(entries[0]), 'let a = *b*;');
153
153
  });
154
154
 
155
155
  it('keeps more than one line of a fenced block', () => {
156
156
  const entries = BuildMarkdown('```\none\ntwo\n```');
157
- assert.strictEqual(contentOf(entries[0]), 'one\ntwo\n');
157
+ assert.strictEqual(contentOf(entries[0]), 'one\ntwo');
158
158
  });
159
159
 
160
160
  it('carries on after a fenced block', () => {
@@ -0,0 +1,58 @@
1
+ import { strict as assert } from 'assert';
2
+ import { BuildMarkdown } from '../src/markdown/markdown';
3
+ import { CodeBlockEntry } from '../src/markdown/entry';
4
+ import { Theme } from '../src/theme';
5
+
6
+ interface Drawn {
7
+ box: { y: number; height: number } | null;
8
+ lines: Array<{ text: string; y: number; baseline: string }>;
9
+ }
10
+
11
+ function recordingCtx(drawn: Drawn): CanvasRenderingContext2D {
12
+ const ctx: any = {
13
+ font: '',
14
+ fillStyle: '',
15
+ textAlign: 'left',
16
+ textBaseline: 'alphabetic',
17
+ measureText: (text: string) => ({ width: text.length * 7, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
18
+ save() { }, restore() { },
19
+ beginPath() { }, fill() { },
20
+ roundRect(_x: number, y: number, _w: number, height: number) { drawn.box = { y, height }; },
21
+ fillText(text: string, _x: number, y: number) { drawn.lines.push({ text, y, baseline: this.textBaseline }); },
22
+ };
23
+ return ctx as CanvasRenderingContext2D;
24
+ }
25
+
26
+ function draw(markdown: string): { drawn: Drawn; size: number } {
27
+ const entry = BuildMarkdown(markdown)[0] as CodeBlockEntry;
28
+ const drawn: Drawn = { box: null, lines: [] };
29
+ entry.render(recordingCtx(drawn), { x: 0, y: 100 }, 1, 400);
30
+ return { drawn, size: entry.text().style().getSize() };
31
+ }
32
+
33
+ describe('a markdown code block', () => {
34
+ it('leaves the same room above its first line as below its last', () => {
35
+ const { drawn, size } = draw('```\nconst a = 1;\nconst b = 2;\n```');
36
+ const box = drawn.box!;
37
+ const first = drawn.lines[0];
38
+ const last = drawn.lines[drawn.lines.length - 1];
39
+
40
+ assert.equal(first.baseline, 'top', 'lines are placed by the top of the text');
41
+ const above = first.y - box.y;
42
+ const below = (box.y + box.height) - (last.y + size);
43
+ assert.equal(above, Theme.Note.CodeBlock.Padding);
44
+ assert.equal(below, Theme.Note.CodeBlock.Padding);
45
+ });
46
+
47
+ it('does not keep the newline before the closing backticks as an empty last line', () => {
48
+ const { drawn } = draw('```\nconst a = 1;\nconst b = 2;\n```');
49
+ assert.deepEqual(drawn.lines.map((l) => l.text), ['const a = 1;', 'const b = 2;']);
50
+ });
51
+
52
+ it('reports the height it drew', () => {
53
+ const entry = BuildMarkdown('```\none\n```')[0] as CodeBlockEntry;
54
+ const drawn: Drawn = { box: null, lines: [] };
55
+ const height = entry.render(recordingCtx(drawn), { x: 0, y: 0 }, 1, 400);
56
+ assert.equal(height, drawn.box!.height);
57
+ });
58
+ });