├── README.md
├── index.html
└── src
├── index.js
├── app.js
└── renderer.js
/README.md:
--------------------------------------------------------------------------------
1 | # canvas-text-layout
2 |
3 | [Demo](https://roman01la.github.io/canvas-text-layout/)
4 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Canvas Text Layout
7 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | const canvas = document.getElementById("canvas");
2 | const ctx = canvas.getContext("2d");
3 |
4 | const getWindowSize = () => ({
5 | w: window.innerWidth * window.devicePixelRatio,
6 | h: window.innerHeight * window.devicePixelRatio
7 | });
8 |
9 | let { w, h } = getWindowSize();
10 |
11 | Object.assign(canvas.style, {
12 | position: "absolute",
13 | top: 0,
14 | left: 0,
15 | transformOrigin: "0 0",
16 | transform: `scale(${1 / window.devicePixelRatio})`
17 | });
18 |
19 | canvas.width = w;
20 | canvas.height = h;
21 |
22 | const worker = new Worker("/src/app.js");
23 |
24 | worker.addEventListener("message", evt => {
25 | if (evt.data.type === "render") {
26 | canvas.width = w;
27 | canvas.height = h;
28 |
29 | ctx.fillStyle = "#fff";
30 | ctx.fillRect(0, 0, w, h);
31 | ctx.drawImage(evt.data.img, 0, 0);
32 | }
33 | });
34 |
35 | worker.postMessage({
36 | type: "init",
37 | dpr: window.devicePixelRatio,
38 | width: w / window.devicePixelRatio,
39 | height: h / window.devicePixelRatio
40 | });
41 |
42 | let rid;
43 |
44 | window.addEventListener(
45 | "resize",
46 | function() {
47 | if (rid) {
48 | cancelAnimationFrame(rid);
49 | id = null;
50 | }
51 | rid = requestAnimationFrame(() => {
52 | const ws = getWindowSize();
53 | w = ws.w;
54 | h = ws.h;
55 |
56 | worker.postMessage({
57 | type: "update",
58 | dpr: window.devicePixelRatio,
59 | width: w / window.devicePixelRatio,
60 | height: h / window.devicePixelRatio
61 | });
62 | });
63 | },
64 | false
65 | );
66 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | importScripts("./renderer.js");
2 |
3 | this.onmessage = evt => {
4 | if (evt.data.type === "init") {
5 | const { dpr, width, height } = evt.data;
6 | updateEnv({ dpr, width, height });
7 | renderLoop();
8 | }
9 | if (evt.data.type === "update") {
10 | const { dpr, width, height } = evt.data;
11 | updateEnv({ dpr, width, height });
12 | }
13 | };
14 |
15 | function renderLoop() {
16 | requestAnimationFrame(() => renderLoop());
17 | const img = renderApp();
18 | this.postMessage({ type: "render", img }, [img]);
19 | }
20 |
21 | function renderApp() {
22 | render(
23 | { left: 0, top: 0, ...this.__env },
24 | view(
25 | {
26 | top: 0,
27 | left: 0,
28 | width: this.__env.width,
29 | height: this.__env.height
30 | },
31 | view(
32 | {
33 | top: 16,
34 | left: 16,
35 | width: "40%",
36 | style: {
37 | borderColor: "red"
38 | }
39 | },
40 | p(
41 | {},
42 | "Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
43 | )
44 | ),
45 | view(
46 | {
47 | top: 16,
48 | left: "45%",
49 | width: "25%",
50 | style: {
51 | borderColor: "red"
52 | }
53 | },
54 | p(
55 | {},
56 | "Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
57 | )
58 | )
59 | )
60 | );
61 |
62 | return this.canvas.transferToImageBitmap();
63 | }
64 |
--------------------------------------------------------------------------------
/src/renderer.js:
--------------------------------------------------------------------------------
1 | const canvas = new OffscreenCanvas(0, 0);
2 | const ctx = canvas.getContext("2d");
3 |
4 | this.canvas = canvas;
5 |
6 | let devicePixelRatio;
7 |
8 | function updateEnv({ dpr, width, height }) {
9 | this.__env = {
10 | dpr,
11 | width,
12 | height
13 | };
14 | devicePixelRatio = dpr;
15 | canvas.width = width * dpr;
16 | canvas.height = height * dpr;
17 | }
18 |
19 | function wrapText({ text, breakWord, maxWidth, lineHeight, font, x, y }) {
20 | ctx.font = font;
21 |
22 | const words = breakWord === "all" ? text.split("") : text.split(" ");
23 | const j = breakWord === "all" ? "" : " ";
24 | const wln = words.length;
25 | const lines = [];
26 | let line = "";
27 |
28 | for (let n = 0; n < wln; n++) {
29 | const eol = n === wln - 1 ? "" : j;
30 | const testLine = line + words[n] + eol;
31 | const metrics = ctx.measureText(testLine);
32 | const testWidth = metrics.width;
33 |
34 | if (testWidth <= maxWidth) {
35 | line = testLine;
36 | } else {
37 | y += lineHeight;
38 | lines.push([x, y, line]);
39 | line = "";
40 | n--;
41 | }
42 | }
43 |
44 | if (line) {
45 | lines.push([x, y + lineHeight, line]);
46 | }
47 |
48 | const height = lines[lines.length - 2][1];
49 |
50 | return { lines, font, height };
51 | }
52 |
53 | function makeFontProp({
54 | fontStyle = "normal",
55 | fontWeight = "normal",
56 | fontSize = 16,
57 | lineHeight = 16,
58 | fontFamily = "Helvetica"
59 | }) {
60 | const dpr = devicePixelRatio;
61 | return [
62 | fontStyle,
63 | fontWeight,
64 | `${fontSize * dpr}px/${lineHeight * dpr}px`,
65 | fontFamily
66 | ].join(" ");
67 | }
68 |
69 | function renderView(
70 | opts,
71 | { children, top = 0, left = 0, height = 0, width = 0, style = {} }
72 | ) {
73 | const dpr = devicePixelRatio;
74 |
75 | const w =
76 | typeof width === "string" ? (parseInt(width) / 100) * opts.width : width;
77 | const l =
78 | typeof left === "string" ? (parseInt(left) / 100) * opts.width : left;
79 |
80 | const childHeight = children.reduce((ret, node) => {
81 | const childNode =
82 | node[Symbol.for("ui/type")] === "text"
83 | ? {
84 | ...node,
85 | left: l,
86 | top,
87 | maxWidth: w * dpr
88 | }
89 | : {
90 | ...node,
91 | maxWidth: w * dpr
92 | };
93 |
94 | const { height } = render(opts, childNode);
95 |
96 | return height > ret ? height : ret;
97 | }, 0);
98 |
99 | const viewHeight = childHeight > height ? childHeight : height;
100 |
101 | if (style.borderColor) {
102 | ctx.strokeStyle = style.borderColor;
103 | ctx.lineWidth = 1 * dpr;
104 | ctx.strokeRect(l * dpr, top * dpr, w * dpr, viewHeight);
105 | }
106 |
107 | return {
108 | height: viewHeight
109 | };
110 | }
111 |
112 | function renderText(opts, node) {
113 | const {
114 | text,
115 | maxWidth,
116 | lineHeight,
117 | color = "#000",
118 | left = 0,
119 | top = 0
120 | } = node;
121 | const dpr = devicePixelRatio;
122 |
123 | const { lines, font, height } = wrapText({
124 | text,
125 | maxWidth,
126 | lineHeight: lineHeight * dpr,
127 | font: makeFontProp(node),
128 | x: left * dpr,
129 | y: top * dpr,
130 | breakWord: "word"
131 | });
132 |
133 | ctx.font = font;
134 | ctx.fillStyle = color;
135 |
136 | lines.forEach(([x, y, text]) => {
137 | ctx.fillText(text, x, y);
138 | });
139 |
140 | return {
141 | height
142 | };
143 | }
144 |
145 | function render(opts, node) {
146 | if (node[Symbol.for("ui/type")] === "view") {
147 | return renderView(opts, node);
148 | }
149 | if (node[Symbol.for("ui/type")] === "text") {
150 | return renderText(opts, node);
151 | }
152 | }
153 |
154 | const view = (opts, ...children) => ({
155 | [Symbol.for("ui/type")]: "view",
156 | ...opts,
157 | children
158 | });
159 |
160 | const h1 = (opts, text) => ({
161 | [Symbol.for("ui/type")]: "text",
162 | text,
163 | fontSize: 18,
164 | lineHeight: 20,
165 | fontWeight: 600,
166 | ...opts
167 | });
168 |
169 | const p = (opts, text) => ({
170 | [Symbol.for("ui/type")]: "text",
171 | text,
172 | fontSize: 14,
173 | lineHeight: 16,
174 | ...opts
175 | });
176 |
--------------------------------------------------------------------------------