├── 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 | --------------------------------------------------------------------------------