@matboks/template-core 0.0.1
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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/template.d.ts +66 -0
- package/dist/template.js +260 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.js +1 -0
- package/dist/utilities/context.d.ts +17 -0
- package/dist/utilities/context.js +49 -0
- package/dist/utilities/helpers.d.ts +18 -0
- package/dist/utilities/helpers.js +129 -0
- package/dist/utilities/search-params.d.ts +10 -0
- package/dist/utilities/search-params.js +48 -0
- package/dist/utilities/svg.d.ts +13 -0
- package/dist/utilities/svg.js +77 -0
- package/package.json +20 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./template.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./template.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Resolution } from "@matboks/utilities";
|
|
2
|
+
import { MakeOptionalRequiredIfIn } from "./types.js";
|
|
3
|
+
import { Context } from "./utilities/context.js";
|
|
4
|
+
type TemplateOptions = {
|
|
5
|
+
projectName: string;
|
|
6
|
+
aspectRatio?: number;
|
|
7
|
+
resolution?: number;
|
|
8
|
+
container?: HTMLElement;
|
|
9
|
+
animate?: boolean;
|
|
10
|
+
numFrames?: number;
|
|
11
|
+
fps?: number;
|
|
12
|
+
numLoops?: number;
|
|
13
|
+
enableSVGExport?: boolean;
|
|
14
|
+
seed?: number;
|
|
15
|
+
};
|
|
16
|
+
type DrawFunction = (data: Context) => void;
|
|
17
|
+
type SetupFunction = (data: Context) => DrawFunction | void;
|
|
18
|
+
declare const defaultOptions: {
|
|
19
|
+
aspectRatio: number;
|
|
20
|
+
container: HTMLElement;
|
|
21
|
+
animate: boolean;
|
|
22
|
+
fps: number;
|
|
23
|
+
numLoops: number;
|
|
24
|
+
enableSVGExport: true;
|
|
25
|
+
seed: number;
|
|
26
|
+
};
|
|
27
|
+
type OptionsWithDefaults = MakeOptionalRequiredIfIn<TemplateOptions, typeof defaultOptions>;
|
|
28
|
+
export declare class Template {
|
|
29
|
+
options: OptionsWithDefaults;
|
|
30
|
+
canvas: HTMLCanvasElement;
|
|
31
|
+
rawCtx: CanvasRenderingContext2D;
|
|
32
|
+
get ctx(): CanvasRenderingContext2D;
|
|
33
|
+
setup?: SetupFunction;
|
|
34
|
+
draw?: DrawFunction;
|
|
35
|
+
private isSetup;
|
|
36
|
+
private isAnimating;
|
|
37
|
+
animationFrame: number;
|
|
38
|
+
realTime: number;
|
|
39
|
+
private previousTime;
|
|
40
|
+
resolution: Resolution;
|
|
41
|
+
seed: number;
|
|
42
|
+
isDebug: boolean;
|
|
43
|
+
rafId: number;
|
|
44
|
+
private templateContext;
|
|
45
|
+
private svgHandler?;
|
|
46
|
+
constructor(options: TemplateOptions);
|
|
47
|
+
static create(options: TemplateOptions): Template;
|
|
48
|
+
updateDocumentTitle(): void;
|
|
49
|
+
private getFrameInfo;
|
|
50
|
+
setupSVG(): void;
|
|
51
|
+
setOptionsToInitial(): void;
|
|
52
|
+
initialize(): void;
|
|
53
|
+
render(): void;
|
|
54
|
+
handleSetup(): void;
|
|
55
|
+
drawFrame(): void;
|
|
56
|
+
startAnimation(): void;
|
|
57
|
+
stopAnimation(): void;
|
|
58
|
+
finishAnimation(): void;
|
|
59
|
+
restart(): void;
|
|
60
|
+
setupResizeListener(): void;
|
|
61
|
+
resizeCanvas(): void;
|
|
62
|
+
private syncCanvasInternalResolution;
|
|
63
|
+
setupKeyListeners(): void;
|
|
64
|
+
getSVG(): string;
|
|
65
|
+
}
|
|
66
|
+
export {};
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Resolution } from "@matboks/utilities";
|
|
2
|
+
import { searchParams } from "./utilities/search-params.js";
|
|
3
|
+
import { getMaximumAvailableResolution, savePNG, saveSVG, openCanvasInNewTab, createHTMLElements, assignDefined, setTheme, debounce } from "./utilities/helpers.js";
|
|
4
|
+
import { context } from "./utilities/context.js";
|
|
5
|
+
import { createSVGHandler } from "./utilities/svg.js";
|
|
6
|
+
const defaultOptions = {
|
|
7
|
+
aspectRatio: 1,
|
|
8
|
+
container: document.body,
|
|
9
|
+
animate: false,
|
|
10
|
+
fps: 60,
|
|
11
|
+
numLoops: 1,
|
|
12
|
+
enableSVGExport: true,
|
|
13
|
+
seed: 0,
|
|
14
|
+
};
|
|
15
|
+
export class Template {
|
|
16
|
+
get ctx() { return this.options.enableSVGExport ? this.svgHandler.ctx2DProxy : this.rawCtx; }
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.isSetup = false;
|
|
19
|
+
this.isAnimating = false;
|
|
20
|
+
this.animationFrame = 0;
|
|
21
|
+
this.realTime = 0;
|
|
22
|
+
this.previousTime = null;
|
|
23
|
+
this.rafId = -1;
|
|
24
|
+
const parsedOptions = assignDefined(defaultOptions, options, {
|
|
25
|
+
seed: searchParams.get("seed", parseInt),
|
|
26
|
+
animate: searchParams.get("animate", (v) => v === "true"),
|
|
27
|
+
resolution: searchParams.get("resolution", parseInt)
|
|
28
|
+
});
|
|
29
|
+
this.options = parsedOptions;
|
|
30
|
+
this.isDebug = searchParams.get("debug", (v) => v === "true") ?? false;
|
|
31
|
+
this.updateDocumentTitle();
|
|
32
|
+
}
|
|
33
|
+
static create(options) {
|
|
34
|
+
const template = new Template(options);
|
|
35
|
+
template.initialize();
|
|
36
|
+
return template;
|
|
37
|
+
}
|
|
38
|
+
updateDocumentTitle() {
|
|
39
|
+
const { projectName } = this.options;
|
|
40
|
+
if (this.isAnimating) {
|
|
41
|
+
document.title = `${this.getFrameInfo()} - ${this.options.projectName}`;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
document.title = projectName;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
getFrameInfo() {
|
|
48
|
+
const { animationFrame, options: { numFrames, numLoops } } = this;
|
|
49
|
+
if (numFrames) {
|
|
50
|
+
if (numLoops > 1) {
|
|
51
|
+
const currentLoop = Math.floor(animationFrame / (numFrames + 1)) + 1;
|
|
52
|
+
const currentFrame = (animationFrame + 1) % (numFrames + 1);
|
|
53
|
+
return `#${currentLoop} ${currentFrame} / ${numFrames}`;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
return `${animationFrame} / ${numFrames}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
return animationFrame.toString();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
setupSVG() {
|
|
64
|
+
const svgHandler = createSVGHandler(this.rawCtx);
|
|
65
|
+
this.svgHandler = svgHandler;
|
|
66
|
+
}
|
|
67
|
+
setOptionsToInitial() {
|
|
68
|
+
const { seed, animate } = this.options;
|
|
69
|
+
this.seed = seed;
|
|
70
|
+
this.isAnimating = animate;
|
|
71
|
+
}
|
|
72
|
+
initialize() {
|
|
73
|
+
this.setOptionsToInitial();
|
|
74
|
+
const { container } = this.options;
|
|
75
|
+
const { root, canvas } = createHTMLElements();
|
|
76
|
+
setTheme(container);
|
|
77
|
+
container.append(root);
|
|
78
|
+
this.canvas = canvas;
|
|
79
|
+
this.rawCtx = this.canvas.getContext("2d");
|
|
80
|
+
// TODO Resize listener triggers initial render. Is this intuitive?
|
|
81
|
+
this.setupResizeListener();
|
|
82
|
+
this.setupKeyListeners();
|
|
83
|
+
}
|
|
84
|
+
render() {
|
|
85
|
+
// debugger
|
|
86
|
+
if (this.options.animate) {
|
|
87
|
+
this.startAnimation();
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
requestAnimationFrame(() => this.drawFrame());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
handleSetup() {
|
|
94
|
+
if (this.isSetup)
|
|
95
|
+
return;
|
|
96
|
+
this.rawCtx.reset();
|
|
97
|
+
if (this.options.enableSVGExport)
|
|
98
|
+
this.setupSVG();
|
|
99
|
+
this.templateContext = context(this);
|
|
100
|
+
const drawFunction = this.setup?.(this.templateContext);
|
|
101
|
+
this.isSetup = true;
|
|
102
|
+
if (drawFunction)
|
|
103
|
+
this.draw = drawFunction;
|
|
104
|
+
}
|
|
105
|
+
drawFrame() {
|
|
106
|
+
this.handleSetup();
|
|
107
|
+
const { previousTime, isAnimating } = this;
|
|
108
|
+
const currentTime = performance.now() / 1000;
|
|
109
|
+
if (isAnimating && previousTime && currentTime - previousTime < 1 / this.options.fps) {
|
|
110
|
+
this.rafId = requestAnimationFrame(() => this.drawFrame());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (previousTime)
|
|
114
|
+
this.realTime += currentTime - previousTime;
|
|
115
|
+
this.previousTime = currentTime;
|
|
116
|
+
this.draw?.(this.templateContext);
|
|
117
|
+
if (this.isAnimating) {
|
|
118
|
+
const { numFrames, numLoops } = this.options;
|
|
119
|
+
this.animationFrame++;
|
|
120
|
+
this.updateDocumentTitle();
|
|
121
|
+
if (numFrames && this.animationFrame === numFrames * numLoops) {
|
|
122
|
+
this.finishAnimation();
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.rafId = requestAnimationFrame(() => this.drawFrame());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
document.complete = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
startAnimation() {
|
|
133
|
+
this.rafId = requestAnimationFrame(() => this.drawFrame());
|
|
134
|
+
this.isAnimating = true;
|
|
135
|
+
}
|
|
136
|
+
stopAnimation() {
|
|
137
|
+
cancelAnimationFrame(this.rafId);
|
|
138
|
+
this.isAnimating = false;
|
|
139
|
+
}
|
|
140
|
+
finishAnimation() {
|
|
141
|
+
this.stopAnimation();
|
|
142
|
+
document.complete = true;
|
|
143
|
+
}
|
|
144
|
+
restart() {
|
|
145
|
+
this.stopAnimation();
|
|
146
|
+
this.isSetup = false;
|
|
147
|
+
this.animationFrame = 0;
|
|
148
|
+
this.realTime = 0;
|
|
149
|
+
this.previousTime = null;
|
|
150
|
+
this.render();
|
|
151
|
+
}
|
|
152
|
+
setupResizeListener() {
|
|
153
|
+
const { canvas } = this;
|
|
154
|
+
const containerResizeObserver = new ResizeObserver(debounce(() => this.resizeCanvas(), 100));
|
|
155
|
+
containerResizeObserver.observe(canvas.parentElement.parentElement);
|
|
156
|
+
this.resizeCanvas();
|
|
157
|
+
}
|
|
158
|
+
resizeCanvas() {
|
|
159
|
+
const { canvas, options } = this;
|
|
160
|
+
const frame = canvas.parentElement;
|
|
161
|
+
const { clientWidth, clientHeight } = frame.parentElement;
|
|
162
|
+
const framePadding = parseInt(getComputedStyle(frame).padding);
|
|
163
|
+
const { width, height } = getMaximumAvailableResolution(new Resolution(clientWidth - 2 * framePadding, clientHeight - 2 * framePadding), options.aspectRatio);
|
|
164
|
+
if (canvas.clientWidth === width && canvas.clientHeight === height)
|
|
165
|
+
return;
|
|
166
|
+
canvas.style.width = `${width}px`;
|
|
167
|
+
canvas.style.height = `${height}px`;
|
|
168
|
+
this.syncCanvasInternalResolution();
|
|
169
|
+
}
|
|
170
|
+
syncCanvasInternalResolution() {
|
|
171
|
+
const { canvas, options } = this;
|
|
172
|
+
let width, height;
|
|
173
|
+
if (!options.resolution) {
|
|
174
|
+
({ width, height } = canvas.getBoundingClientRect());
|
|
175
|
+
width = Math.round(devicePixelRatio * width);
|
|
176
|
+
height = Math.round(devicePixelRatio * height);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const { resolution, aspectRatio } = options;
|
|
180
|
+
[width, height] = aspectRatio > 1 ?
|
|
181
|
+
[resolution, resolution / aspectRatio] :
|
|
182
|
+
[aspectRatio * resolution, resolution];
|
|
183
|
+
}
|
|
184
|
+
canvas.width = width;
|
|
185
|
+
canvas.height = height;
|
|
186
|
+
this.resolution = new Resolution(width, height);
|
|
187
|
+
this.restart();
|
|
188
|
+
}
|
|
189
|
+
setupKeyListeners() {
|
|
190
|
+
const { canvas, seed, options } = this;
|
|
191
|
+
const getSeed = () => searchParams.get("seed", parseInt) ?? 0;
|
|
192
|
+
// TODO make syncronisation of values more robust (options and searchParams)
|
|
193
|
+
window.addEventListener("keydown", (event) => {
|
|
194
|
+
switch (event.key) {
|
|
195
|
+
case "a": {
|
|
196
|
+
const animate = this.options.animate = searchParams.toggle("animate");
|
|
197
|
+
if (animate) {
|
|
198
|
+
this.startAnimation();
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
this.stopAnimation();
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "n": {
|
|
206
|
+
this.seed = searchParams.set("seed", getSeed() - 1);
|
|
207
|
+
this.restart();
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "m": {
|
|
211
|
+
this.seed = searchParams.set("seed", getSeed() + 1);
|
|
212
|
+
this.restart();
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "d": {
|
|
216
|
+
this.isDebug = searchParams.toggle("debug");
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "t": {
|
|
220
|
+
// Darkmode
|
|
221
|
+
const value = localStorage.getItem("theme");
|
|
222
|
+
if (!value || value === "light") {
|
|
223
|
+
this.options.container.classList.add("dark-template");
|
|
224
|
+
localStorage.setItem("theme", "dark");
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.options.container.classList.remove("dark-template");
|
|
228
|
+
localStorage.setItem("theme", "light");
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "c": {
|
|
233
|
+
this.setOptionsToInitial();
|
|
234
|
+
searchParams.clearAll();
|
|
235
|
+
this.restart();
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "s": {
|
|
239
|
+
const filename = [options.projectName, seed, options.resolution].filter((v) => v).join("-");
|
|
240
|
+
savePNG(filename, this);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "v": {
|
|
244
|
+
const filename = [options.projectName, seed].filter((v) => v).join("-");
|
|
245
|
+
saveSVG(filename, this);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "o": {
|
|
249
|
+
openCanvasInNewTab(canvas);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
getSVG() {
|
|
256
|
+
if (!this.svgHandler)
|
|
257
|
+
throw new Error("SVG is not enabled");
|
|
258
|
+
return this.svgHandler.getSVG();
|
|
259
|
+
}
|
|
260
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type MakeOptionalRequiredIfIn<A, B> = Required<Pick<A, Extract<keyof A, keyof B> & {
|
|
2
|
+
[K in keyof A]-?: {} extends Pick<A, K> ? K : never;
|
|
3
|
+
}[keyof A]>> & Omit<A, {
|
|
4
|
+
[K in keyof A]-?: {} extends Pick<A, K> ? (K extends keyof B ? K : never) : never;
|
|
5
|
+
}[keyof A]>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Template } from "../template.js";
|
|
2
|
+
export declare function context(template: Template): {
|
|
3
|
+
template: Template;
|
|
4
|
+
ctx: CanvasRenderingContext2D;
|
|
5
|
+
clear: (backgroundStyle?: string) => void;
|
|
6
|
+
isolate: (fn: () => void) => void;
|
|
7
|
+
stopAnimation: () => void;
|
|
8
|
+
stopWhen: (condition: boolean) => void;
|
|
9
|
+
readonly frameTime: number;
|
|
10
|
+
readonly realTime: number;
|
|
11
|
+
readonly progress: number;
|
|
12
|
+
readonly frame: number;
|
|
13
|
+
readonly resolution: import("@matboks/utilities").Resolution;
|
|
14
|
+
readonly seed: number;
|
|
15
|
+
readonly isDebug: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type Context = ReturnType<typeof context>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mod } from "@matboks/utilities";
|
|
2
|
+
// TODO differentiate between frameTime (frame / fps) and progressionTime (frame / numFrames)
|
|
3
|
+
export function context(template) {
|
|
4
|
+
const { ctx, options } = template;
|
|
5
|
+
const isolate = (fn) => {
|
|
6
|
+
ctx.save();
|
|
7
|
+
fn();
|
|
8
|
+
ctx.restore();
|
|
9
|
+
};
|
|
10
|
+
const clear = (backgroundStyle) => {
|
|
11
|
+
const { width, height } = template.resolution;
|
|
12
|
+
isolate(() => {
|
|
13
|
+
ctx.resetTransform();
|
|
14
|
+
if (backgroundStyle) {
|
|
15
|
+
ctx.fillStyle = backgroundStyle;
|
|
16
|
+
ctx.fillRect(0, 0, width, height);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
ctx.clearRect(0, 0, width, height);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
const stopAnimation = () => {
|
|
24
|
+
template.finishAnimation();
|
|
25
|
+
};
|
|
26
|
+
const stopWhen = (condition) => {
|
|
27
|
+
if (condition)
|
|
28
|
+
stopAnimation();
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
template, ctx,
|
|
32
|
+
clear, isolate,
|
|
33
|
+
stopAnimation, stopWhen,
|
|
34
|
+
get frameTime() {
|
|
35
|
+
return template.animationFrame / options.fps;
|
|
36
|
+
},
|
|
37
|
+
get realTime() {
|
|
38
|
+
return template.realTime;
|
|
39
|
+
},
|
|
40
|
+
get progress() {
|
|
41
|
+
const { numFrames } = options;
|
|
42
|
+
return numFrames ? mod(template.animationFrame, numFrames) / numFrames : 0;
|
|
43
|
+
},
|
|
44
|
+
get frame() { return template.animationFrame; },
|
|
45
|
+
get resolution() { return template.resolution; },
|
|
46
|
+
get seed() { return template.seed; },
|
|
47
|
+
get isDebug() { return template.isDebug; }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Resolution } from "@matboks/utilities";
|
|
2
|
+
import { Template } from "../template.js";
|
|
3
|
+
export declare function getMaximumAvailableResolution(availableSpace: Resolution, aspectRatio: number): Resolution;
|
|
4
|
+
export declare function savePNG(filename: string, template: Template): void;
|
|
5
|
+
export declare function saveSVG(filename: string, template: Template): void;
|
|
6
|
+
export declare function openCanvasInNewTab(canvas: HTMLCanvasElement): void;
|
|
7
|
+
export declare function createHTMLElements(): {
|
|
8
|
+
root: HTMLDivElement;
|
|
9
|
+
canvas: HTMLCanvasElement;
|
|
10
|
+
};
|
|
11
|
+
type NonNullish<T> = Exclude<T, null | undefined>;
|
|
12
|
+
type MergeDefined<T extends Record<PropertyKey, any>[], R = {}> = T extends [infer F, ...infer Rest] ? MergeDefined<Rest extends Record<PropertyKey, any>[] ? Rest : [], {
|
|
13
|
+
[K in keyof R | keyof F]: K extends keyof F ? NonNullish<F[K]> extends never ? R extends Record<K, any> ? R[K] : F[K] : NonNullish<F[K]> : R extends Record<K, any> ? R[K] : never;
|
|
14
|
+
}> : R;
|
|
15
|
+
export declare function assignDefined<const T extends Record<string, any>[]>(...objects: T): MergeDefined<T>;
|
|
16
|
+
export declare function setTheme(container: HTMLElement): void;
|
|
17
|
+
export declare function debounce(fn: () => void, ms: number): () => void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Resolution } from "@matboks/utilities";
|
|
2
|
+
export function getMaximumAvailableResolution(availableSpace, aspectRatio) {
|
|
3
|
+
const { width: availableWidth, height: availableHeight } = availableSpace;
|
|
4
|
+
const [width, height] = (aspectRatio * availableHeight > availableWidth ?
|
|
5
|
+
[availableWidth, availableWidth / aspectRatio] :
|
|
6
|
+
[aspectRatio * availableHeight, availableHeight]).map(Math.round);
|
|
7
|
+
return new Resolution(width, height);
|
|
8
|
+
}
|
|
9
|
+
export function savePNG(filename, template) {
|
|
10
|
+
const link = document.createElement("a");
|
|
11
|
+
link.setAttribute('download', `${filename}.png`);
|
|
12
|
+
const data = template.canvas.toDataURL("image/png").replace("image/png", "image/octet-stream");
|
|
13
|
+
;
|
|
14
|
+
link.setAttribute('href', data);
|
|
15
|
+
link.click();
|
|
16
|
+
link.remove();
|
|
17
|
+
}
|
|
18
|
+
export function saveSVG(filename, template) {
|
|
19
|
+
const link = document.createElement("a");
|
|
20
|
+
link.setAttribute('download', `${filename}.svg`);
|
|
21
|
+
const data = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(template.getSVG());
|
|
22
|
+
link.setAttribute('href', data);
|
|
23
|
+
link.click();
|
|
24
|
+
link.remove();
|
|
25
|
+
}
|
|
26
|
+
export function openCanvasInNewTab(canvas) {
|
|
27
|
+
const data = canvas.toDataURL("png");
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.src = data;
|
|
30
|
+
const newWindow = window.open();
|
|
31
|
+
img.style.maxHeight = "100%";
|
|
32
|
+
newWindow.document.body.style.margin = "0";
|
|
33
|
+
newWindow.document.body.innerHTML = img.outerHTML;
|
|
34
|
+
}
|
|
35
|
+
export function createHTMLElements() {
|
|
36
|
+
const cssId = "_" + crypto.randomUUID().slice(0, 8);
|
|
37
|
+
const template = document.createElement("template");
|
|
38
|
+
template.innerHTML = `
|
|
39
|
+
<div class="template ${cssId}">
|
|
40
|
+
<div class="container ${cssId}">
|
|
41
|
+
<div class="frame ${cssId}">
|
|
42
|
+
<canvas class="canvas ${cssId}"></canvas>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
const styleElement = document.createElement("style");
|
|
48
|
+
styleElement.textContent = `
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
.template.${cssId} {
|
|
52
|
+
box-sizing: border-box;
|
|
53
|
+
width: 100dvw;
|
|
54
|
+
height: 100dvh;
|
|
55
|
+
|
|
56
|
+
background-color: var(--background-color);
|
|
57
|
+
padding-inline: 5dvw;
|
|
58
|
+
padding-block: 5dvh;
|
|
59
|
+
|
|
60
|
+
display: flex;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
align-items: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
:root {
|
|
66
|
+
--background-color: #fafafa;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.dark-template {
|
|
70
|
+
--background-color: #1a191e;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.container.${cssId} {
|
|
74
|
+
width: 100%;
|
|
75
|
+
height: 100%;
|
|
76
|
+
|
|
77
|
+
display: flex;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
align-items: center;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.frame.${cssId} {
|
|
83
|
+
display: flex;
|
|
84
|
+
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
|
|
85
|
+
|
|
86
|
+
padding: 10px;
|
|
87
|
+
|
|
88
|
+
background-color: white;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.canvas.${cssId} {
|
|
92
|
+
outline: 1px solid rgba(0, 0, 0, 0.07);
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
document.head.appendChild(styleElement);
|
|
96
|
+
const root = template.content.children[0];
|
|
97
|
+
const canvas = root.querySelector("canvas");
|
|
98
|
+
return { root, canvas };
|
|
99
|
+
}
|
|
100
|
+
// Like Object.assign, but does not overwrite defined values with null or undefined
|
|
101
|
+
export function assignDefined(...objects) {
|
|
102
|
+
const result = {};
|
|
103
|
+
for (const obj of objects) {
|
|
104
|
+
for (const key in obj) {
|
|
105
|
+
const value = obj[key];
|
|
106
|
+
if (value !== null && value !== undefined) {
|
|
107
|
+
result[key] = value;
|
|
108
|
+
}
|
|
109
|
+
else if (!(key in result)) {
|
|
110
|
+
// Keep initial null/undefined only if no earlier non-nullish existed
|
|
111
|
+
result[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
export function setTheme(container) {
|
|
118
|
+
if (localStorage.getItem("theme") === "dark") {
|
|
119
|
+
container.classList.add("dark-template");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function debounce(fn, ms) {
|
|
123
|
+
let timeoutId = null;
|
|
124
|
+
return function () {
|
|
125
|
+
if (timeoutId !== null)
|
|
126
|
+
clearTimeout(timeoutId);
|
|
127
|
+
timeoutId = window.setTimeout(fn, ms);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class SearchParams {
|
|
2
|
+
url: URL;
|
|
3
|
+
get(name: string): string | null;
|
|
4
|
+
get<R>(name: string, parser: (value: string) => R): R | null;
|
|
5
|
+
clearAll(): void;
|
|
6
|
+
pushToHistory(): void;
|
|
7
|
+
toggle(name: string): boolean;
|
|
8
|
+
set<T>(name: string, value: T): T;
|
|
9
|
+
}
|
|
10
|
+
export declare const searchParams: SearchParams;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class SearchParams {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.url = new URL(window.location.toString());
|
|
4
|
+
}
|
|
5
|
+
get(name, parser) {
|
|
6
|
+
const value = this.url.searchParams.get(name);
|
|
7
|
+
if (!value)
|
|
8
|
+
return null;
|
|
9
|
+
if (parser) {
|
|
10
|
+
return parser(value);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
clearAll() {
|
|
15
|
+
this.url.search = "";
|
|
16
|
+
this.pushToHistory();
|
|
17
|
+
}
|
|
18
|
+
pushToHistory() {
|
|
19
|
+
const path = this.url.toString();
|
|
20
|
+
window.history.pushState({ path }, "", path);
|
|
21
|
+
}
|
|
22
|
+
toggle(name) {
|
|
23
|
+
const { searchParams } = this.url;
|
|
24
|
+
let state;
|
|
25
|
+
if (searchParams.get(name) === "true") {
|
|
26
|
+
searchParams.delete(name);
|
|
27
|
+
state = false;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
searchParams.set(name, "true");
|
|
31
|
+
state = true;
|
|
32
|
+
}
|
|
33
|
+
this.pushToHistory();
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
set(name, value) {
|
|
37
|
+
const { searchParams } = this.url;
|
|
38
|
+
if (value === null || value === undefined) {
|
|
39
|
+
searchParams.delete(name);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
searchParams.set(name, value.toString());
|
|
43
|
+
}
|
|
44
|
+
this.pushToHistory();
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export const searchParams = new SearchParams();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Context as SVGContext } from "svgcanvas";
|
|
2
|
+
type SVGCommand = (svgCtx: SVGContext) => void;
|
|
3
|
+
export declare class SVGHandler {
|
|
4
|
+
svgCtx: SVGContext;
|
|
5
|
+
ctx2DProxy: CanvasRenderingContext2D;
|
|
6
|
+
drawCommands: Array<SVGCommand>;
|
|
7
|
+
constructor(ctx: CanvasRenderingContext2D);
|
|
8
|
+
pushCommand(command: SVGCommand): void;
|
|
9
|
+
private applyCommands;
|
|
10
|
+
getSVG(): string;
|
|
11
|
+
}
|
|
12
|
+
export declare function createSVGHandler(ctx: CanvasRenderingContext2D): SVGHandler;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
import { Context as SVGContext } from "svgcanvas";
|
|
3
|
+
export class SVGHandler {
|
|
4
|
+
constructor(ctx) {
|
|
5
|
+
this.drawCommands = [];
|
|
6
|
+
const { width, height } = ctx.canvas;
|
|
7
|
+
this.svgCtx = new SVGContext({ ctx, width, height });
|
|
8
|
+
}
|
|
9
|
+
pushCommand(command) {
|
|
10
|
+
this.drawCommands.push(command);
|
|
11
|
+
}
|
|
12
|
+
applyCommands() {
|
|
13
|
+
for (const command of this.drawCommands)
|
|
14
|
+
command(this.svgCtx);
|
|
15
|
+
this.drawCommands = [];
|
|
16
|
+
}
|
|
17
|
+
getSVG() {
|
|
18
|
+
this.applyCommands();
|
|
19
|
+
const { svgCtx } = this;
|
|
20
|
+
const svg = svgCtx.getSvg();
|
|
21
|
+
const scale = this.svgCtx.getTransform().a;
|
|
22
|
+
// Fix dash array not being scaled by transform. Note: lineDashOffset is not handled
|
|
23
|
+
for (const element of svg.querySelectorAll("[stroke-dasharray]")) {
|
|
24
|
+
const newValues = element.getAttribute("stroke-dasharray").split(",").map((v) => scale * parseFloat(v));
|
|
25
|
+
element.setAttribute("stroke-dasharray", newValues.join(","));
|
|
26
|
+
}
|
|
27
|
+
return svgCtx.getSerializedSvg();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function createSVGHandler(ctx) {
|
|
31
|
+
const svgHandler = new SVGHandler(ctx);
|
|
32
|
+
svgHandler.ctx2DProxy = new Proxy(ctx, {
|
|
33
|
+
get(target, prop, receiver) {
|
|
34
|
+
// First retrieve the underlying value from the real canvas context
|
|
35
|
+
const value = Reflect.get(target, prop, receiver);
|
|
36
|
+
// If it's a function, wrap it so both contexts are called
|
|
37
|
+
if (typeof value === "function") {
|
|
38
|
+
return function (...args) {
|
|
39
|
+
// Call original Canvas 2D method
|
|
40
|
+
const result = value.apply(target, args);
|
|
41
|
+
// Call SVG counterpart ONLY if it exists and is a function
|
|
42
|
+
svgHandler.pushCommand((svgContext) => {
|
|
43
|
+
const svgFn = svgContext[prop];
|
|
44
|
+
if (typeof svgFn === "function") {
|
|
45
|
+
svgFn.apply(svgContext, args);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Otherwise: plain property -> return the real property value
|
|
52
|
+
return value;
|
|
53
|
+
},
|
|
54
|
+
set(target, prop, newValue) {
|
|
55
|
+
// First update the original canvas context
|
|
56
|
+
target[prop] = newValue;
|
|
57
|
+
// Mirror to SVG context if that property exists
|
|
58
|
+
svgHandler.pushCommand((svgContext) => {
|
|
59
|
+
if (prop in svgContext) {
|
|
60
|
+
try {
|
|
61
|
+
// Some SVG context properties may be read-only -> guard with try
|
|
62
|
+
svgContext[prop] = newValue;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// silently ignore if readonly
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
has(target, prop) {
|
|
72
|
+
// Behave like a normal CanvasRenderingContext2D
|
|
73
|
+
return prop in target;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return svgHandler;
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@matboks/template-core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"typescript": "~5.6.2",
|
|
11
|
+
"vite": "^6.0.1"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"svgcanvas": "^2.6.0",
|
|
15
|
+
"@matboks/utilities": "0.0.1"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc"
|
|
19
|
+
}
|
|
20
|
+
}
|