├── .gitattributes ├── docs ├── public │ ├── Inter.ttf │ ├── avatar.jpeg │ ├── favicon.ico │ ├── blog │ │ └── 0.1.0.png │ ├── Inter-Regular.otf │ ├── Inter-SemiBold.otf │ └── logo.svg ├── bun.lockb ├── .gitignore ├── .prettierrc ├── postcss.config.js ├── app │ ├── components │ │ ├── PackageJson.tsx │ │ ├── Tabs.tsx │ │ ├── Markdown.tsx │ │ ├── withClient.tsx │ │ ├── CopyCode.tsx │ │ ├── Blogpost.tsx │ │ ├── Table.tsx │ │ ├── BaseEditor.tsx │ │ ├── Enums.tsx │ │ ├── Tabs.client.tsx │ │ ├── TypeTooltip.tsx │ │ ├── Sidebar.tsx │ │ ├── HighlightMatches.tsx │ │ ├── Outline.tsx │ │ ├── CodeBlock.tsx │ │ ├── BaseEditor.client.tsx │ │ └── Body.tsx │ ├── page.mdx │ ├── math-library │ │ ├── page.mdx │ │ └── Api.tsx │ ├── blog │ │ ├── (posts) │ │ │ └── layout.tsx │ │ └── page.tsx │ ├── interactivity │ │ ├── Api.tsx │ │ └── page.mdx │ ├── getMetadata.ts │ ├── text-rendering │ │ ├── Api.tsx │ │ └── page.mdx │ ├── examples │ │ ├── page.mdx │ │ └── editors │ │ │ ├── Basic.tsx │ │ │ └── Scrollbars.tsx │ ├── roadmap │ │ └── page.mdx │ ├── renderer │ │ ├── Api.tsx │ │ └── page.mdx │ ├── globals.css │ ├── styling │ │ ├── Api.tsx │ │ └── page.mdx │ ├── layout-engine │ │ ├── Api.tsx │ │ └── page.mdx │ ├── code-theme.css │ ├── layout.tsx │ ├── search │ │ └── route.tsx │ └── getting-started │ │ └── page.mdx ├── index.d.ts ├── .eslintrc.cjs ├── next-env.d.ts ├── tsconfig.json ├── mdx-components.tsx ├── remarkTypography.mjs ├── next.config.mjs ├── remarkUniqueIds.mjs ├── tailwind.config.js └── package.json ├── .prettierrc ├── bun.lockb ├── vercel.json ├── .npmignore ├── examples ├── public │ ├── Inter.ttf │ ├── Inter-Bold.ttf │ ├── Lora-Regular.ttf │ ├── ComicNeue-Bold.ttf │ ├── Inter-SemiBold.ttf │ ├── Rubik-Regular.ttf │ └── JetBrainsMono-Regular.ttf ├── measure.ts ├── index.html └── main.ts ├── .gitignore ├── src ├── utils │ ├── invariant.ts │ ├── getByTestId.ts │ ├── createTextureFromBitmap.ts │ ├── Tree.ts │ ├── LRUCache.ts │ ├── Queue.ts │ ├── Queue.test.ts │ ├── parseColor.test.ts │ ├── Tree.test.ts │ └── parseColor.ts ├── widgets │ ├── colors.ts │ ├── updateText.ts │ ├── Button.ts │ └── updateSelection.ts ├── layout │ ├── Node.ts │ ├── Text.ts │ ├── compose.ts │ ├── paint.test.ts │ ├── BaseView.ts │ ├── eventTypes.ts │ └── View.ts ├── consts.ts ├── math │ ├── packShelves.test.ts │ ├── triangulatePolygon.test.ts │ ├── triangulateLine.ts │ ├── Vec2.ts │ ├── Vec3.ts │ ├── Mat4.test.ts │ ├── utils.ts │ ├── Vec4.ts │ ├── packShelves.ts │ └── triangulatePolygon.ts ├── renderer │ ├── Renderer.ts │ └── CanvasRenderer.ts ├── font │ ├── shapeText.test.ts │ ├── generateGlyphToClassMap.ts │ ├── types.ts │ ├── calculateGlyphQuads.ts │ ├── toSDF.ts │ ├── renderFontAtlas.ts │ ├── prepareLookups.ts │ ├── BinaryReader.ts │ ├── generateKerningFunction.ts │ └── shapeText.ts ├── hitTest.ts └── index.ts ├── tsconfig.build.json ├── vitest.config.ts ├── .vscode ├── settings.json └── launch.json ├── vite.config.ts ├── CONTRIBUTING.md ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── MAINTAINERS.md ├── package.json ├── .eslintrc.cjs └── test.html /.gitattributes: -------------------------------------------------------------------------------- 1 | *.lockb binary diff=lockb -------------------------------------------------------------------------------- /docs/public/Inter.ttf: -------------------------------------------------------------------------------- 1 | ../../public/Inter.ttf -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/bun.lockb -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/bun.lockb -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | app/types.json 3 | node_modules/ 4 | tsconfig.tsbuildinfo 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # For some reason interTTF.json from tests was getting copied to dist/. 2 | assets/ 3 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /docs/public/avatar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/avatar.jpeg -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /examples/public/Inter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter.ttf -------------------------------------------------------------------------------- /docs/public/blog/0.1.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/blog/0.1.0.png -------------------------------------------------------------------------------- /docs/public/Inter-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/Inter-Regular.otf -------------------------------------------------------------------------------- /docs/public/Inter-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/Inter-SemiBold.otf -------------------------------------------------------------------------------- /examples/public/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter-Bold.ttf -------------------------------------------------------------------------------- /examples/public/Lora-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Lora-Regular.ttf -------------------------------------------------------------------------------- /examples/public/ComicNeue-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/ComicNeue-Bold.ttf -------------------------------------------------------------------------------- /examples/public/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /examples/public/Rubik-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Rubik-Regular.ttf -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vercel/ 3 | node_modules/ 4 | coverage/ 5 | dist/ 6 | *.tsbuildinfo 7 | .rollup.cache/ 8 | .sandpack-files/ 9 | -------------------------------------------------------------------------------- /docs/app/components/PackageJson.tsx: -------------------------------------------------------------------------------- 1 | import _packageJson from "../../../package.json"; 2 | export const packageJson = _packageJson; 3 | -------------------------------------------------------------------------------- /examples/public/JetBrainsMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/JetBrainsMono-Regular.ttf -------------------------------------------------------------------------------- /docs/index.d.ts: -------------------------------------------------------------------------------- 1 | import FlexsearchTypes from "@types/flexsearch"; 2 | 3 | declare module "flexsearch" { 4 | export = FlexsearchTypes; 5 | } 6 | -------------------------------------------------------------------------------- /docs/app/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { List, Trigger, Root, Content } from "./Tabs.client"; 2 | 3 | export const Tabs = { Content, List, Root, Trigger }; 4 | -------------------------------------------------------------------------------- /src/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | export function invariant(value: unknown, message?: string): asserts value { 2 | if (!value) { 3 | throw new Error(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/app/page.mdx: -------------------------------------------------------------------------------- 1 | import Readme from "../../README.md"; 2 | import { getMetadata } from "./getMetadata"; 3 | export const metadata = getMetadata("Homepage"); 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["../.eslintrc.cjs", "next/core-web-vitals"], 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: "./tsconfig.json", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "noEmit": false, 6 | "declaration": true, 7 | "sourceMap": true 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/measure.ts: -------------------------------------------------------------------------------- 1 | export function measure(tag: string, operation: () => void) { 2 | const start = performance.now(); 3 | operation(); 4 | const end = performance.now(); 5 | console.log(`${tag} took ${(end - start).toFixed(2)}ms.`); 6 | } 7 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: "istanbul", 7 | reporter: ["lcov"], 8 | }, 9 | include: ["src/**/*.test.ts"], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/widgets/colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | gray: [ 3 | "#111111", 4 | "#191919", 5 | "#222222", 6 | "#2A2A2A", 7 | "#313131", 8 | "#3A3A3A", 9 | "#484848", 10 | "#606060", 11 | "#6E6E6E", 12 | "#7B7B7B", 13 | "#B4B4B4", 14 | "#EEEEEE", 15 | ], 16 | } as const; 17 | -------------------------------------------------------------------------------- /docs/app/math-library/page.mdx: -------------------------------------------------------------------------------- 1 | import { Api } from "./Api"; 2 | import { getMetadata } from "../getMetadata"; 3 | export const metadata = getMetadata("Math Library"); 4 | 5 | # Math Library 6 | 7 | Since the whole project is entirely zero-dependency, it comes with its own math library. 8 | 9 | ## API 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/app/blog/(posts)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import type { PropsWithChildren } from "react"; 3 | 4 | export default function layout({ children }: PropsWithChildren) { 5 | return ( 6 | <> 7 | 8 | <- Back to blog 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [{ "column": 100 }], 3 | // Auto-format on save. 4 | "editor.formatOnSave": true, 5 | // Auto-fix eslint errors/warnings on save. 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | // Inform VSCode that there are multiple ESLint configs. 10 | "eslint.workingDirectories": [".", "docs"] 11 | } 12 | -------------------------------------------------------------------------------- /docs/app/interactivity/Api.tsx: -------------------------------------------------------------------------------- 1 | import types from "../types.json"; 2 | import { ApiBlock, Class } from "../components/ApiBlocks"; 3 | 4 | export function Api() { 5 | return ( 6 | 7 | {Object.values(types.classes) 8 | .filter((c) => c.source.startsWith("/EventManager")) 9 | .map((c, i) => { 10 | return ; 11 | })} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: resolve(__dirname, "src/index.ts"), 9 | // fileName: () => `index.js`, 10 | formats: ["es"], 11 | name: "RedOtter", 12 | }, 13 | outDir: ".sandpack-files", 14 | }, 15 | plugins: [dts({ rollupTypes: true, strictOutput: true })], 16 | }); 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:5005", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | It's still early stage and there are some fundamental decisions to be made in which I probably don't expect much external input. 4 | 5 | However, the surface of the library is expanding so there's a lot of things to optimize and improve. See potential to make some function faster? Feel free to open an issue or even a PR. 6 | 7 | See some bigger problem that can't be solved easily without trade-offs? Please open an issue. 8 | 9 | See a bug? Please open an issue, I really appreciate it. 10 | -------------------------------------------------------------------------------- /src/utils/getByTestId.ts: -------------------------------------------------------------------------------- 1 | import { BaseView } from "../layout/BaseView"; 2 | import type { Node } from "../layout/Node"; 3 | 4 | export function getByTestId(root: Node, testId: string): Node | null { 5 | let c = root.firstChild; 6 | while (c) { 7 | if (c.testID === testId) { 8 | return c; 9 | } 10 | if (c instanceof BaseView) { 11 | const inSubTree = getByTestId(c, testId); 12 | if (inSubTree) { 13 | return inSubTree; 14 | } 15 | } 16 | c = c.next; 17 | } 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /docs/app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | // @ts-expect-error MDX posts are not recognized by TS. 4 | import BigReset from "./(posts)/0-1-0-the-big-reset/page.mdx"; 5 | import { getMetadata } from "../getMetadata"; 6 | 7 | export const metadata = getMetadata("Blog"); 8 | 9 | export default function Page() { 10 | return ( 11 | <> 12 | 13 | 14 | Direct link -> 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "moduleResolution": "node", 7 | "plugins": [{ "name": "next" }] 8 | }, 9 | "include": [ 10 | "next-env.d.ts", 11 | "**/*.ts", 12 | "**/*.tsx", 13 | ".next/types/**/*.ts", 14 | ".eslintrc.cjs", 15 | "postcss.config.js", 16 | "tailwind.config.js", 17 | "next.config.mjs", 18 | "remarkTypography.mjs", 19 | "remarkUniqueIds.mjs" 20 | ], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /docs/app/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import { evaluateSync } from "@mdx-js/mdx"; 2 | import { myComponents } from "../../mdx-components"; 3 | import { Fragment, createElement } from "react"; 4 | 5 | /** 6 | * When Markdown needs to be rendered from a string. 7 | */ 8 | export function Markdown({ children }: { children: string }) { 9 | const jsxContent = evaluateSync(children, { 10 | Fragment, 11 | format: "md", 12 | jsx: createElement, 13 | jsxs: createElement, 14 | }); 15 | 16 | const Content = jsxContent.default; 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /src/layout/Node.ts: -------------------------------------------------------------------------------- 1 | import type { ExactLayoutProps, LayoutNodeState } from "./styling"; 2 | 3 | /** 4 | * Basic node in the layout tree. Containing its state and style information as well as pointers to 5 | * its children, siblings, and parent. 6 | */ 7 | export interface Node { 8 | /** 9 | * State of the node updated by the layout engine. 10 | */ 11 | _state: LayoutNodeState; 12 | _style: ExactLayoutProps; 13 | firstChild: Node | null; 14 | lastChild: Node | null; 15 | next: Node | null; 16 | parent: Node | null; 17 | prev: Node | null; 18 | testID: string | null; 19 | } 20 | -------------------------------------------------------------------------------- /docs/app/components/withClient.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { ComponentProps, ComponentType } from "react"; 3 | import { useEffect, useState } from "react"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | export function withClient>(Component: T) { 7 | return function ClientComponent(props: ComponentProps) { 8 | const [mounted, setMounted] = useState(false); 9 | 10 | useEffect(() => { 11 | setMounted(true); 12 | }, []); 13 | 14 | if (!mounted) { 15 | return null; 16 | } 17 | 18 | return ; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/createTextureFromBitmap.ts: -------------------------------------------------------------------------------- 1 | export function createTextureFromImageBitmap( 2 | device: GPUDevice, 3 | imageBitmap: ImageBitmap, 4 | ): GPUTexture { 5 | const size = { height: imageBitmap.height, width: imageBitmap.width }; 6 | 7 | const texture = device.createTexture({ 8 | format: "rgba8unorm", 9 | label: "image bitmap", 10 | size, 11 | usage: 12 | GPUTextureUsage.TEXTURE_BINDING | 13 | GPUTextureUsage.COPY_DST | 14 | GPUTextureUsage.RENDER_ATTACHMENT, 15 | }); 16 | 17 | device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, size); 18 | 19 | return texture; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "downlevelIteration": true, 5 | "esModuleInterop": true, 6 | "incremental": true, 7 | "isolatedModules": true, 8 | "lib": ["es2022", "dom", "dom.iterable"], 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "noEmit": true, 12 | "noUncheckedIndexedAccess": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "target": "es2022", 17 | "typeRoots": ["./node_modules/@webgpu/types"] 18 | }, 19 | "include": ["src", "examples", ".eslintrc.cjs", "vite.config.ts", "vitest.config.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /docs/app/getMetadata.ts: -------------------------------------------------------------------------------- 1 | import { packageJson } from "./components/PackageJson"; 2 | 3 | export function getMetadata(title: string, description?: string, publishedTime?: string) { 4 | let url = `/og?title=${encodeURI(title)}`; 5 | if (publishedTime) { 6 | url += `&publishedTime=${publishedTime}`; 7 | } 8 | 9 | return { 10 | description: description ?? packageJson.description, 11 | openGraph: { 12 | description: description ?? packageJson.description, 13 | images: [ 14 | { 15 | height: 630, 16 | url, 17 | width: 1200, 18 | }, 19 | ], 20 | locale: "en_US", 21 | title, 22 | type: "website", 23 | }, 24 | title, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/Tree.ts: -------------------------------------------------------------------------------- 1 | export class Tree { 2 | next: Tree | null = null; 3 | prev: Tree | null = null; 4 | firstChild: Tree | null = null; 5 | lastChild: Tree | null = null; 6 | parent: Tree | null = null; 7 | 8 | constructor(public value: T) {} 9 | 10 | add(node: Tree): Tree { 11 | node.parent = this; 12 | 13 | if (this.firstChild === null) { 14 | this.firstChild = node; 15 | this.lastChild = node; 16 | } else { 17 | if (this.lastChild === null) { 18 | throw new Error("Last child must be set."); 19 | } 20 | 21 | node.prev = this.lastChild; 22 | this.lastChild.next = node; 23 | this.lastChild = node; 24 | } 25 | 26 | return node; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LINE_HEIGHT_MULTIPLIER = 1.4; 2 | 3 | export const CROSS_AXIS_SIZE = 10; 4 | export const SCROLLBAR_COLOR = "#191919"; 5 | export const SCROLLBAR_CORNER_COLOR = "#191919"; 6 | export const SCROLLBAR_TRACK_COLOR = "#3A3A3A"; 7 | export const SCROLLBAR_TRACK_HOVER_COLOR = "#484848"; 8 | 9 | export const isWindowDefined = typeof window !== "undefined"; 10 | 11 | const windowWidth = isWindowDefined ? window.innerWidth : 1024; 12 | const windowHeight = isWindowDefined ? window.innerHeight : 768; 13 | 14 | export const settings = { 15 | rectangleBufferSize: 16 * 4096, 16 | sampleCount: 4, 17 | textBufferSize: 16 * 100_000, 18 | windowHeight, 19 | windowWidth, 20 | }; 21 | 22 | export type Settings = typeof settings; 23 | -------------------------------------------------------------------------------- /src/math/packShelves.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Vec2 } from "./Vec2"; 3 | import { packShelves } from "./packShelves"; 4 | 5 | describe("packShelves", () => { 6 | it("should work", () => { 7 | const rectangles = [ 8 | new Vec2(10, 10), 9 | new Vec2(40, 40), 10 | new Vec2(15, 15), 11 | new Vec2(15, 15), 12 | new Vec2(50, 50), 13 | ]; 14 | const packing = packShelves(rectangles); 15 | 16 | expect(packing.width).toBe(128); 17 | expect(packing.height).toBe(128); 18 | 19 | expect(packing.positions[0]?.x).toBe(40); 20 | expect(packing.positions[0]?.y).toBe(65); 21 | expect(packing.positions[3]?.x).toBe(55); 22 | expect(packing.positions[3]?.y).toBe(50); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/math/triangulatePolygon.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Vec2 } from "./Vec2"; 3 | import { triangulatePolygon } from "./triangulatePolygon"; 4 | 5 | describe("triangulatePolygon", () => { 6 | it("works", () => { 7 | const data = [ 8 | new Vec2(0, 80), 9 | new Vec2(100, 0), 10 | new Vec2(190, 85), 11 | new Vec2(270, 35), 12 | new Vec2(345, 140), 13 | new Vec2(255, 130), 14 | new Vec2(215, 210), 15 | new Vec2(140, 70), 16 | new Vec2(45, 95), 17 | new Vec2(50, 185), 18 | ]; 19 | const triangles = triangulatePolygon(data); 20 | expect(triangles).toHaveLength(24); 21 | expect(triangles[0]!.equals(new Vec2(50, 185))).toBeTruthy(); 22 | expect(triangles[1]!.equals(new Vec2(45, 95))).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/renderer/Renderer.ts: -------------------------------------------------------------------------------- 1 | import type { Vec2 } from "../math/Vec2"; 2 | import type { Vec4 } from "../math/Vec4"; 3 | import type { TextAlign } from "../layout/styling"; 4 | 5 | export interface Renderer { 6 | rectangle( 7 | color: Vec4, 8 | position: Vec2, 9 | size: Vec2, 10 | corners: Vec4, 11 | borderWidth: Vec4, 12 | borderColor: Vec4, 13 | clipStart: Vec2, 14 | clipSize: Vec2, 15 | clipCorners: Vec4, 16 | ): void; 17 | 18 | render(context: CanvasRenderingContext2D | GPUCommandEncoder): void; 19 | 20 | text( 21 | text: string, 22 | position: Vec2, 23 | fontName: string, 24 | fontSize: number, 25 | color: Vec4, 26 | textAlignment: TextAlign, 27 | clipStart: Vec2, 28 | clipSize: Vec2, 29 | options?: { lineHeight?: number; maxWidth?: number; noWrap?: boolean }, 30 | ): void; 31 | } 32 | -------------------------------------------------------------------------------- /src/font/shapeText.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { shapeText } from "./shapeText"; 3 | import interTTF from "../../assets/interTTF.json"; // This is a 1.8MB JSON file (95kB gzipped). 4 | import { prepareLookups } from "../font/prepareLookups"; 5 | import type { TTF } from "../font/parseTTF"; 6 | import { TextAlign } from "../layout/styling"; 7 | 8 | const alphabet = 9 | "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890 ,.:•-–()[]{}!?@#$%^&*+=/\\|<>`~’'\";_"; 10 | const lookups = prepareLookups( 11 | [{ buffer: new ArrayBuffer(0), name: "Inter", ttf: interTTF as TTF }], 12 | { alphabet, fontSize: 150 }, 13 | ); 14 | 15 | describe("shapeText", () => { 16 | it("should work", () => { 17 | expect(true).toBe(true); 18 | 19 | shapeText(lookups, "Inter", 15, 20, "Hello, World!", TextAlign.Left); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /docs/app/components/CopyCode.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { twMerge } from "tailwind-merge"; 4 | import { outline } from "./tags"; 5 | import { useState } from "react"; 6 | 7 | export function CopyCode({ code }: { code: string }) { 8 | const [state, setState] = useState<"idle" | "success">("idle"); 9 | return ( 10 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/widgets/updateText.ts: -------------------------------------------------------------------------------- 1 | import type { KeyboardEvent } from "../layout/eventTypes"; 2 | 3 | export function updateText( 4 | value: string, 5 | cursor: number, 6 | mark: number, 7 | event: KeyboardEvent, 8 | ): { cursor: number; mark: number; value: string } { 9 | const valueAsNumbers = value.split("").map((character) => character.charCodeAt(0)); 10 | const selectionLength = Math.abs(cursor - mark); 11 | 12 | // If selection length is greater than 0, delete the selection first. 13 | if (selectionLength > 0) { 14 | valueAsNumbers.splice(Math.min(cursor, mark), selectionLength); 15 | cursor = Math.min(cursor, mark); 16 | mark = cursor; 17 | } 18 | 19 | valueAsNumbers.splice(cursor, 0, event.code); 20 | cursor += 1; 21 | mark = cursor; 22 | 23 | return { 24 | cursor, 25 | mark, 26 | value: valueAsNumbers.map((code) => String.fromCharCode(code)).join(""), 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /docs/app/components/Blogpost.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import { slugify } from "./tags"; 5 | 6 | export function Blogpost({ title, date, author }: { author: string; date: string; title: string }) { 7 | const avatar = 8 | author === "Tomasz Czajecki" ? ( 9 | Tomasz Czajecki 16 | ) : null; 17 | 18 | return ( 19 | <> 20 | 21 |

{title}

22 | 23 |
24 | {format(new Date(date), "do LLL y")} by {avatar} 25 | {author} 26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /docs/app/math-library/Api.tsx: -------------------------------------------------------------------------------- 1 | import types from "../types.json"; 2 | import { ApiBlock, Class, Function } from "../components/ApiBlocks"; 3 | import { Hr } from "../components/tags"; 4 | 5 | export function Api() { 6 | return ( 7 | 8 | {Object.values(types.classes) 9 | .filter((c) => c.source.startsWith("/math")) 10 | .map((c, i) => { 11 | return ( 12 | <> 13 |
14 | 15 | 16 | ); 17 | })} 18 | {Object.values(types.functions) 19 | .filter((f) => f.source.startsWith("/math")) 20 | .map((f, i) => { 21 | return ( 22 | <> 23 |
24 | 25 | 26 | ); 27 | })} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /docs/app/text-rendering/Api.tsx: -------------------------------------------------------------------------------- 1 | import types from "../types.json"; 2 | import { ApiBlock, Class, Function } from "../components/ApiBlocks"; 3 | import { Hr } from "../components/tags"; 4 | 5 | export function Api() { 6 | return ( 7 | 8 | {Object.values(types.functions) 9 | .filter((f) => f.source.startsWith("/font")) 10 | .map((f, i) => { 11 | return ( 12 | <> 13 |
14 | 15 | 16 | ); 17 | })} 18 | {Object.values(types.classes) 19 | .filter((c) => c.source.startsWith("/font")) 20 | .map((c, i) => { 21 | return ( 22 | <> 23 |
24 | 25 | 26 | ); 27 | })} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /docs/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from "mdx/types"; 2 | import { 3 | A, 4 | H2, 5 | H4, 6 | Img, 7 | Li, 8 | Ol, 9 | P, 10 | Strong, 11 | Ul, 12 | Code, 13 | H1, 14 | H3, 15 | Box, 16 | Hr, 17 | } from "./app/components/tags"; 18 | import { Enums } from "./app/components/Enums"; 19 | import { CodeBlock } from "./app/components/CodeBlock"; 20 | import { Blogpost } from "./app/components/Blogpost"; 21 | 22 | export const myComponents = { 23 | A, 24 | Blogpost, 25 | Box, 26 | Code, 27 | Enums, 28 | a: A, 29 | blockquote: Box, 30 | code: Code, 31 | h1: H1, 32 | h2: H2, 33 | h3: H3, 34 | h4: H4, 35 | hr: Hr, 36 | img: Img, 37 | li: Li, 38 | ol: Ol, 39 | p: P, 40 | pre: CodeBlock, 41 | strong: Strong, 42 | ul: Ul, 43 | }; 44 | 45 | export function useMDXComponents(components: MDXComponents): MDXComponents { 46 | return { 47 | ...components, 48 | ...myComponents, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/font/generateGlyphToClassMap.ts: -------------------------------------------------------------------------------- 1 | import type { ClassDefFormat1, ClassDefFormat2 } from "./parseTTF"; 2 | 3 | /** 4 | * @param classDef class definition table. 5 | * @returns a map from glyph ID to class ID. 6 | */ 7 | export function generateGlyphToClassMap( 8 | classDef: ClassDefFormat1 | ClassDefFormat2, 9 | ): Map { 10 | const glyphToClass = new Map(); 11 | 12 | if (classDef.format === 1) { 13 | // ClassDefFormat1 14 | let glyphID = classDef.startGlyph; 15 | for (const classValue of classDef.classes) { 16 | glyphToClass.set(glyphID, classValue); 17 | glyphID++; 18 | } 19 | } else if (classDef.format === 2) { 20 | // ClassDefFormat2 21 | for (const range of classDef.ranges) { 22 | for (let glyphID = range.startGlyphID; glyphID <= range.endGlyphID; glyphID++) { 23 | glyphToClass.set(glyphID, range.class); 24 | } 25 | } 26 | } 27 | 28 | return glyphToClass; 29 | } 30 | -------------------------------------------------------------------------------- /src/widgets/Button.ts: -------------------------------------------------------------------------------- 1 | import type { Lookups } from "../font/types"; 2 | import { Text } from "../layout/Text"; 3 | import { View } from "../layout/View"; 4 | import type { TextStyleProps, ViewStyleProps } from "../layout/styling"; 5 | 6 | export class Button extends View { 7 | constructor(props: { 8 | label: string; 9 | lookups: Lookups; 10 | onClick?(): void; 11 | style?: ViewStyleProps; 12 | testID?: string; 13 | textStyle?: Partial; 14 | }) { 15 | // Put default styles here. 16 | const mergedStyle: ViewStyleProps = { backgroundColor: "#ffd000", ...props.style }; 17 | super({ ...props, style: mergedStyle }); 18 | 19 | this.add( 20 | new Text(props.label, { 21 | lookups: props.lookups, 22 | style: { 23 | color: "#FFFFFF", 24 | fontName: "InterBold", 25 | fontSize: 14, 26 | ...props.textStyle, 27 | }, 28 | }), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/remarkTypography.mjs: -------------------------------------------------------------------------------- 1 | import { visit } from "unist-util-visit"; 2 | 3 | /** 4 | * @typedef {import('unist').Node} Node 5 | * @typedef {Object} TextNode 6 | * @property {string} type 7 | * @property {string} value 8 | */ 9 | 10 | /** 11 | * Type guard for checking if a node is a TextNode. 12 | * 13 | * @param {Node} node 14 | * @returns {node is TextNode} 15 | */ 16 | function isTextNode(node) { 17 | return node.type === "text"; 18 | } 19 | 20 | /** 21 | * Replaces straight quotes with curly ones in a TextNode. 22 | * 23 | * @param {TextNode} node 24 | */ 25 | function replaceQuotes(node) { 26 | node.value = node.value.replaceAll('"', "“").replaceAll("'", "’"); 27 | } 28 | 29 | /** 30 | * Transformer function for the remark plugin. 31 | * 32 | * @returns {import('unified').Transformer} 33 | */ 34 | function remarkTypography() { 35 | return (tree) => { 36 | visit(tree, "text", (node) => { 37 | if (isTextNode(node)) { 38 | replaceQuotes(node); 39 | } 40 | }); 41 | }; 42 | } 43 | 44 | export default remarkTypography; 45 | -------------------------------------------------------------------------------- /src/utils/LRUCache.ts: -------------------------------------------------------------------------------- 1 | export class LRUCache { 2 | private readonly capacity: number; 3 | private readonly cache: Map; 4 | 5 | constructor(capacity: number) { 6 | this.capacity = capacity; 7 | this.cache = new Map(); 8 | } 9 | 10 | get(key: K): V | undefined { 11 | if (!this.cache.has(key)) { 12 | return undefined; 13 | } 14 | 15 | // Move the accessed item to the end of the map (most recently used). 16 | const value = this.cache.get(key); 17 | this.cache.delete(key); 18 | this.cache.set(key, value!); 19 | return value; 20 | } 21 | 22 | put(key: K, value: V): void { 23 | if (this.cache.has(key)) { 24 | this.cache.delete(key); 25 | } 26 | 27 | this.cache.set(key, value); 28 | 29 | // Remove the least recently used item if we're over capacity. 30 | if (this.cache.size > this.capacity) { 31 | const leastRecentlyUsedKey = this.cache.keys().next().value; 32 | this.cache.delete(leastRecentlyUsedKey); 33 | } 34 | } 35 | 36 | size(): number { 37 | return this.cache.size; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/font/types.ts: -------------------------------------------------------------------------------- 1 | import type { Vec2 } from "../math/Vec2"; 2 | import type { Vec4 } from "../math/Vec4"; 3 | import type { TTF } from "./parseTTF"; 4 | 5 | export type Glyph = { 6 | character: string; 7 | height: number; 8 | /** 9 | * Unicode code point. Do not confuse with TTF glyph index. 10 | */ 11 | id: number; 12 | /** 13 | * Left side bearing. 14 | */ 15 | lsb: number; 16 | /** 17 | * Right side bearing. 18 | */ 19 | rsb: number; 20 | width: number; 21 | x: number; 22 | y: number; 23 | }; 24 | 25 | export type KerningFunction = (leftGlyph: number, rightGlyph: number) => number; 26 | 27 | export type Lookups = { 28 | atlas: { 29 | fontSize: number; 30 | height: number; 31 | positions: Array; 32 | sizes: Array; 33 | width: number; 34 | }; 35 | fonts: Array<{ 36 | ascender: number; 37 | buffer: ArrayBuffer; 38 | capHeight: number; 39 | glyphs: Map; 40 | kern: KerningFunction; 41 | name: string; 42 | ttf: TTF; 43 | unitsPerEm: number; 44 | }>; 45 | uvs: Map; 46 | }; 47 | -------------------------------------------------------------------------------- /src/layout/Text.ts: -------------------------------------------------------------------------------- 1 | import type { Lookups } from "../font/types"; 2 | import type { Node } from "./Node"; 3 | import type { ExactLayoutProps, LayoutNodeState, LayoutProps, TextStyleProps } from "./styling"; 4 | import { defaultLayoutNodeState, normalizeLayoutProps } from "./styling"; 5 | 6 | /** 7 | * Basic text node. The only way to create text. It cannot have children. 8 | */ 9 | export class Text implements Node { 10 | testID: string | null; 11 | next: Node | null = null; 12 | prev: Node | null = null; 13 | firstChild: Node | null = null; 14 | lastChild: Node | null = null; 15 | parent: Node | null = null; 16 | /** 17 | * Should always be normalized. 18 | */ 19 | _style: TextStyleProps & ExactLayoutProps; 20 | _state: LayoutNodeState = { ...defaultLayoutNodeState }; 21 | 22 | constructor( 23 | public text: string, 24 | readonly props: { 25 | lookups: Lookups; 26 | style: TextStyleProps; 27 | testID?: string; 28 | }, 29 | ) { 30 | this.testID = props.testID ?? null; 31 | this._style = normalizeLayoutProps(props.style as LayoutProps); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/CanvasRenderer.ts: -------------------------------------------------------------------------------- 1 | import type { TextAlign } from "../layout/styling"; 2 | import type { Vec2 } from "../math/Vec2"; 3 | import type { Vec4 } from "../math/Vec4"; 4 | import type { Renderer } from "./Renderer"; 5 | 6 | export class CanvasRenderer implements Renderer { 7 | rectangle( 8 | color: Vec4, 9 | position: Vec2, 10 | size: Vec2, 11 | corners: Vec4, 12 | borderWidth: Vec4, 13 | borderColor: Vec4, 14 | clipStart: Vec2, 15 | clipSize: Vec2, 16 | clipCorners: Vec4, 17 | ): void { 18 | throw new Error("Method not implemented."); 19 | } 20 | 21 | render(context: CanvasRenderingContext2D): void { 22 | throw new Error("Method not implemented."); 23 | } 24 | 25 | text( 26 | text: string, 27 | position: Vec2, 28 | fontName: string, 29 | fontSize: number, 30 | color: Vec4, 31 | textAlignment: TextAlign, 32 | clipStart: Vec2, 33 | clipSize: Vec2, 34 | options?: { lineHeight?: number; maxWidth?: number; noWrap?: boolean } | undefined, 35 | ): void { 36 | throw new Error("Method not implemented."); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/app/examples/page.mdx: -------------------------------------------------------------------------------- 1 | import { getMetadata } from "../getMetadata"; 2 | import { ExampleBasic } from "./editors/Basic"; 3 | import { ExamplePicker } from "./editors/Picker"; 4 | import { ExampleScrollbars } from "./editors/Scrollbars"; 5 | 6 | export const metadata = getMetadata("Examples"); 7 | 8 | # Examples 9 | 10 | ## Basic rendering 11 | 12 | Example of basic rendering that does not use the layout algorithm. 13 | 14 | 15 | 16 | ## Interactive UIs 17 | 18 | Text inputs focus on click and can be typed into. Text can be selected either with mouse or with shift + arrow keys (or cmd/ctrl to jump to either end). 19 | 20 | Scrollable views can be scrolled with mouse wheel or by dragging the scrollbars with mouse. 21 | 22 | If anything doesn't work or you find a bug, please [open an issue](https://github.com/tchayen/red-otter/issues/new) (but please do check if it hasn't already been reported). 23 | 24 | 25 | 26 | ## Scrollbars 27 | 28 | Example (and a benchmark of sorts) of proper scrolling capabilities in the library – nested scrollable views. 29 | 30 | 31 | -------------------------------------------------------------------------------- /docs/app/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | type CellProps = PropsWithChildren<{ className?: string }>; 5 | 6 | function Cell({ children, className }: CellProps) { 7 | return ( 8 |
14 | {children} 15 |
16 | ); 17 | } 18 | 19 | function Root({ children, columns }: PropsWithChildren<{ columns?: string }>) { 20 | return ( 21 |
25 | {children} 26 |
27 | ); 28 | } 29 | 30 | export function HeaderCell({ children }: PropsWithChildren) { 31 | return ( 32 | 33 | {children} 34 | 35 | ); 36 | } 37 | 38 | export const Table = { Cell, HeaderCell, Root }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-Present Tomasz Czajęcki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/app/components/BaseEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { SandpackProps } from "@codesandbox/sandpack-react/types"; 2 | import { BaseEditorClient } from "./BaseEditor.client"; 3 | // import redOtter from "../../../.sandpack-files/red-otter.js?raw"; 4 | import fs from "node:fs"; 5 | 6 | const basePath = "/../../../../../.sandpack-files"; 7 | const source = fs.promises.readFile(__dirname + basePath + "/red-otter.js", "utf8"); 8 | const types = fs.promises.readFile(__dirname + basePath + "/index.d.ts", "utf8"); 9 | 10 | export async function BaseEditor(props: SandpackProps) { 11 | return ( 12 | \n${await types}`, 23 | }, 24 | "/dist/index.js": { code: await source }, 25 | "sandbox.config.json": { code: sandboxConfig, hidden: true }, 26 | }} 27 | /> 28 | ); 29 | } 30 | 31 | const sandboxConfig = `{"infiniteLoopProtection":false,"hardReloadOnChange":false,"view":"browser"}`; 32 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import addMdx from "@next/mdx"; 4 | import remarkGfm from "remark-gfm"; 5 | import rehypeMdxCodeProps from "rehype-mdx-code-props"; 6 | import remarkTypography from "./remarkTypography.mjs"; 7 | import remarkUniqueIds from "./remarkUniqueIds.mjs"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | /** @type {import('next').NextConfig} */ 13 | const nextConfig = { 14 | pageExtensions: ["ts", "tsx", "md", "mdx"], 15 | reactStrictMode: true, 16 | swcMinify: true, 17 | webpack: (config) => { 18 | return { 19 | ...config, 20 | resolve: { 21 | ...config.resolve, 22 | fallback: { 23 | react: path.resolve(__dirname, "node_modules/react"), 24 | "react/jsx-dev-runtime": path.resolve(__dirname, "node_modules/react/jsx-dev-runtime.js"), 25 | }, 26 | }, 27 | }; 28 | }, 29 | }; 30 | 31 | export default addMdx({ 32 | extension: /\.mdx?$/, 33 | options: { 34 | rehypePlugins: [rehypeMdxCodeProps], 35 | remarkPlugins: [remarkGfm, remarkTypography, remarkUniqueIds], 36 | }, 37 | })(nextConfig); 38 | -------------------------------------------------------------------------------- /src/math/triangulateLine.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from "../utils/invariant"; 2 | import { Vec2 } from "./Vec2"; 3 | 4 | /** 5 | * Given a line defined by a list of points, returns a flat array of points which are vertices of 6 | * triangles. 7 | */ 8 | export function triangulateLine(points: Array, thickness: number): Array { 9 | invariant(points.length >= 2, "Line must have at least 2 points."); 10 | invariant(thickness > 0, "Thickness must be positive."); 11 | 12 | const triangles: Array = []; 13 | 14 | for (let i = 0; i < points.length - 1; i++) { 15 | const point = points[i]; 16 | const nextPoint = points[i + 1]; 17 | invariant(point, "Point is missing."); 18 | invariant(nextPoint, "Next point is missing."); 19 | 20 | const dx = nextPoint.x - point.x; 21 | const dy = nextPoint.y - point.y; 22 | const n1 = new Vec2(dy, -dx).normalize().scale(thickness / 2); 23 | const n2 = new Vec2(-dy, dx).normalize().scale(thickness / 2); 24 | 25 | triangles.push( 26 | nextPoint.add(n2), 27 | point.add(n2), 28 | point.add(n1), 29 | point.add(n1), 30 | nextPoint.add(n1), 31 | nextPoint.add(n2), 32 | ); 33 | } 34 | 35 | return triangles; 36 | } 37 | -------------------------------------------------------------------------------- /docs/app/roadmap/page.mdx: -------------------------------------------------------------------------------- 1 | import { getMetadata } from "../getMetadata"; 2 | export const metadata = getMetadata("Roadmap"); 3 | 4 | # Roadmap 5 | 6 | Things that would be nice to have, roughly sorted by priority: 7 | 8 | - Lots of small bug fixes that I wish I had done before the release but I ran out of steam. 9 | - zIndex support. 10 | - Proper focus system for inputs and buttons. 11 | - SDF border radius clipping. Currently it's just rectangle, which means that elements can overflow in the corners. 12 | - Better line height and text support. Currently it works, mostly, but is far from perfect and even making a simple button might require manual vertical padding adjustment to make it look good. 13 | - Ability to style scrollbars. 14 | - Font ligatures support. 15 | - More styling options: `outline`, `boxShadow`, background gradients. 16 | - Image rendering. 17 | - Non-SDF font rendering that supports regular font atlases from rasterized fonts. 18 | - Rendering vector shapes. Maybe a mini SVG `` renderer? 19 | - Position `fixed` and `sticky`. 20 | - Animations. 21 | - Custom shaders for views. 22 | - Better OTF file support. Currently the library is only able to parse fonts that follow classic TTF-style declaration of glyphs and their sizes. 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI actions 2 | run-name: CI actions (${{ github.actor }}) 3 | on: [push] 4 | 5 | jobs: 6 | lib: 7 | name: Run tests, eslint, tsc, and build 8 | runs-on: ubuntu-latest 9 | env: 10 | CI: 1 11 | steps: 12 | - name: Checkout git 13 | uses: actions/checkout@v3 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 21 18 | - name: Install bun 19 | uses: oven-sh/setup-bun@v1 20 | - name: Install dependencies 21 | run: bun i 22 | - name: Run all checks 23 | run: bun run ci 24 | docs: 25 | name: Run everything in docs too 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout git 29 | uses: actions/checkout@v3 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 21 34 | - name: Install bun 35 | uses: oven-sh/setup-bun@v1 36 | - name: CD into docs 37 | run: cd docs 38 | - name: Install dependencies 39 | run: bun i 40 | - name: eslint 41 | run: bun run lint 42 | - name: tsc 43 | run: bun run tsc 44 | - name: Build 45 | run: bun run build 46 | -------------------------------------------------------------------------------- /docs/app/interactivity/page.mdx: -------------------------------------------------------------------------------- 1 | import { Api } from "./Api"; 2 | import { getMetadata } from "../getMetadata"; 3 | export const metadata = getMetadata("Interactivity"); 4 | 5 | # Interactivity 6 | 7 | The recent version of Red Otter includes new systems for handling user inputs. This required the layout to become more opinionated. 8 | 9 | ## API 10 | 11 | Example of a simple app loop that reads events: 12 | 13 | ```ts 14 | import { EventManager, WebGPURenderer, View, compose, paint } from "red-otter"; 15 | 16 | const eventManager = new EventManager(); 17 | const renderer = new WebGPURenderer(/* ... */); 18 | const root = new View(/* ... */); 19 | 20 | function render() { 21 | // Deliver events to views. 22 | eventManager.deliverEvents(root); 23 | 24 | // Update scroll offsets etc. in response to events. 25 | compose(renderer, root); 26 | 27 | // Dispatch commands for renderer. 28 | paint(renderer, root); 29 | 30 | // Prepare rendering instructions and dispatch them to the GPU. 31 | const commandEncoder = device.createCommandEncoder(); 32 | renderer.render(commandEncoder); 33 | device.queue.submit([commandEncoder.finish()]); 34 | 35 | // Request next frame. 36 | requestAnimationFrame(render); 37 | } 38 | 39 | render(); 40 | ``` 41 | 42 | --- 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/remarkUniqueIds.mjs: -------------------------------------------------------------------------------- 1 | import { visit } from "unist-util-visit"; 2 | 3 | /** 4 | * Transformer function for the remark plugin. 5 | * 6 | * @returns {import('unified').Transformer} 7 | */ 8 | function remarkUniqueIds() { 9 | return (tree) => { 10 | // Initialize counters for each heading level 11 | const counters = {}; 12 | 13 | visit(tree, "heading", (node) => { 14 | // Initialize the counter for this level if not already done 15 | if (!counters[node.depth]) { 16 | counters[node.depth] = 0; 17 | } 18 | 19 | // Increment counter for this level 20 | counters[node.depth]++; 21 | 22 | // Reset counters of all deeper levels 23 | Object.keys(counters) 24 | .map(Number) 25 | .filter((depth) => depth > node.depth) 26 | .forEach((depth) => (counters[depth] = 0)); 27 | 28 | // Generate the ID 29 | let id = ""; 30 | for (let i = 1; i <= node.depth; i++) { 31 | id += `${counters[i]}.`; 32 | } 33 | id = id.slice(0, -1); // Remove the last dot 34 | 35 | // Assign the ID to the node data 36 | node.data = node.data || {}; 37 | node.data.hProperties = node.data.hProperties || {}; 38 | node.data.hProperties.id = id; 39 | }); 40 | }; 41 | } 42 | 43 | export default remarkUniqueIds; 44 | -------------------------------------------------------------------------------- /docs/app/renderer/Api.tsx: -------------------------------------------------------------------------------- 1 | import types from "../types.json"; 2 | import { ApiBlock, Class, Function, Interface } from "../components/ApiBlocks"; 3 | import { Hr } from "../components/tags"; 4 | 5 | export function Api() { 6 | return ( 7 | 8 | {Object.values(types.functions) 9 | .filter((f) => f.source.startsWith("/renderer")) 10 | .map((f, index) => { 11 | return ( 12 | <> 13 |
14 | 15 | 16 | ); 17 | })} 18 | {Object.values(types.interfaces) 19 | .filter((f) => f.source.startsWith("/renderer")) 20 | .map((i, index) => { 21 | return ( 22 | <> 23 |
24 | 25 | 26 | ); 27 | })} 28 | {Object.values(types.classes) 29 | .filter((c) => c.source.startsWith("/renderer")) 30 | .map((c, index) => { 31 | return ( 32 | <> 33 |
34 | 35 | 36 | ); 37 | })} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /docs/app/components/Enums.tsx: -------------------------------------------------------------------------------- 1 | import { H2, H3, P } from "./tags"; 2 | import types from "../types.json"; 3 | import { Table } from "./Table"; 4 | import { Fragment } from "react"; 5 | import { Markdown } from "./Markdown"; 6 | 7 | export function Enums() { 8 | return ( 9 | <> 10 |

Enums

11 |

List of enums used in the types above.

12 | {types.enums 13 | .filter((e) => e.source.startsWith("/layout/styling.ts")) 14 | .map((e) => { 15 | return ( 16 | <> 17 |

{e.name}

18 | {e.description} 19 | 20 | Value 21 | Description 22 | {e.values.map((v) => { 23 | return ( 24 | 25 | {v.name} 26 | 27 | {v.description} 28 | 29 | 30 | ); 31 | })} 32 | 33 | 34 | ); 35 | })} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /docs/app/components/Tabs.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { type PropsWithChildren } from "react"; 3 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 4 | import { twMerge } from "tailwind-merge"; 5 | import { outline } from "./tags"; 6 | 7 | export function Root({ defaultValue, children }: PropsWithChildren<{ defaultValue: string }>) { 8 | return {children}; 9 | } 10 | 11 | export function List({ children }: PropsWithChildren) { 12 | return ( 13 | {children} 14 | ); 15 | } 16 | 17 | export function Trigger({ children, value }: PropsWithChildren<{ value: string }>) { 18 | return ( 19 | 26 | {children} 27 | 28 | ); 29 | } 30 | 31 | export function Content({ children, value }: PropsWithChildren<{ value: string }>) { 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | mauve, 3 | mauveDark, 4 | blue, 5 | blueDark, 6 | orange, 7 | orangeDark, 8 | purple, 9 | purpleDark, 10 | crimson, 11 | crimsonDark, 12 | tomato, 13 | tomatoDark, 14 | amber, 15 | amberDark, 16 | grass, 17 | grassDark, 18 | } = require("@radix-ui/colors"); 19 | 20 | /** @type {import('tailwindcss').Config} */ 21 | module.exports = { 22 | content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"], 23 | darkMode: "class", 24 | plugins: [], 25 | theme: { 26 | extend: { 27 | colors: { 28 | ...mauve, 29 | ...renameKeys("mauve", mauveDark), 30 | ...blue, 31 | ...renameKeys("blue", blueDark), 32 | ...orange, 33 | ...renameKeys("orange", orangeDark), 34 | ...purple, 35 | ...renameKeys("purple", purpleDark), 36 | ...crimson, 37 | ...renameKeys("crimson", crimsonDark), 38 | ...tomato, 39 | ...renameKeys("tomato", tomatoDark), 40 | ...amber, 41 | ...renameKeys("amber", amberDark), 42 | ...grass, 43 | ...renameKeys("grass", grassDark), 44 | }, 45 | }, 46 | }, 47 | }; 48 | 49 | function renameKeys(name, colors) { 50 | return Object.fromEntries( 51 | Object.entries(colors).map(([key, value]) => { 52 | return [`${name}dark${key.replace(name, "")}`, value]; 53 | }), 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/Queue.ts: -------------------------------------------------------------------------------- 1 | interface QueueNode { 2 | data: T; 3 | next: QueueNode | null; 4 | prev: QueueNode | null; 5 | } 6 | 7 | export class Queue { 8 | private start: QueueNode | null = null; 9 | private end: QueueNode | null = null; 10 | private size = 0; 11 | 12 | enqueue(value: T): void { 13 | const node: QueueNode = { 14 | data: value, 15 | next: null, 16 | prev: null, 17 | }; 18 | 19 | if (this.end) { 20 | this.end.next = node; 21 | node.prev = this.end; 22 | } else { 23 | this.start = node; 24 | } 25 | 26 | this.end = node; 27 | this.size += 1; 28 | } 29 | 30 | dequeue(): T | null { 31 | const node = this.start; 32 | if (node === null) { 33 | return null; 34 | } 35 | 36 | if (node.next) { 37 | this.start = node.next; 38 | } else { 39 | this.start = null; 40 | this.end = null; 41 | } 42 | 43 | this.size -= 1; 44 | return node.data; 45 | } 46 | 47 | dequeueFront(): T | null { 48 | const node = this.end; 49 | if (node === null) { 50 | return null; 51 | } 52 | 53 | if (node.prev) { 54 | this.end = node.prev; 55 | } else { 56 | this.start = null; 57 | this.end = null; 58 | } 59 | 60 | this.size -= 1; 61 | return node.data; 62 | } 63 | 64 | isEmpty(): boolean { 65 | return this.size === 0; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | color-scheme: dark; 7 | font-family: Inter, sans-serif; 8 | } 9 | 10 | body { 11 | padding: 0; 12 | margin: 0; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | font-feature-settings: "ss04", "tnum", "ss08"; 20 | } 21 | 22 | canvas { 23 | border-radius: 8px; 24 | } 25 | 26 | .scrollbar::-webkit-scrollbar, 27 | .cm-scroller::-webkit-scrollbar { 28 | width: 8px; 29 | height: 8px; 30 | } 31 | 32 | .scrollbar::-webkit-scrollbar-track, 33 | .cm-scroller::-webkit-scrollbar-track { 34 | background: transparent; 35 | } 36 | 37 | .scrollbar::-webkit-scrollbar-corner, 38 | .cm-scroller::-webkit-scrollbar-corner { 39 | background: transparent; 40 | } 41 | 42 | .scrollbar::-webkit-scrollbar-thumb, 43 | .cm-scroller::-webkit-scrollbar-thumb { 44 | background: var(--scrollbar-thumb); 45 | border-radius: 4px; 46 | } 47 | 48 | .scrollbar::-webkit-scrollbar-thumb:hover, 49 | .cm-scroller::-webkit-scrollbar-thumb:hover { 50 | background: var(--scrollbar-thumb-hover); 51 | } 52 | 53 | .scrollbar::-webkit-scrollbar-thumb:active, 54 | .cm-scroller::-webkit-scrollbar-thumb:active { 55 | background: var(--scrollbar-thumb-active); 56 | } 57 | 58 | * { 59 | box-sizing: border-box; 60 | } 61 | -------------------------------------------------------------------------------- /docs/app/styling/Api.tsx: -------------------------------------------------------------------------------- 1 | import { ApiBlock, Type } from "../components/ApiBlocks"; 2 | import { Enums } from "../components/Enums"; 3 | import types from "../types.json"; 4 | import { A, Box, Hr, P } from "../components/tags"; 5 | 6 | export function Api() { 7 | const layoutProps = types.types.LayoutProps; 8 | const decorativeProps = types.types.DecorativeProps; 9 | const textStyleProps = types.types.TextStyleProps; 10 | const layoutNodeState = types.types.LayoutNodeState; 11 | 12 | return ( 13 | 14 | 15 |
16 | 17 |
18 | 19 |

20 | In-depth explanation of text rendering is available on the [Text 21 | Rendering](/text-rendering) page. 22 |

23 | 24 |

25 | The library uses cap size as opposed to line height for calculating bounding box of text 26 | elements (see CapSize for more 27 | explanation). This results in most noticeable differences in buttons which require more 28 | vertical space than in browsers. 29 |

30 |
31 |
32 |
33 | 34 |
35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /docs/app/layout-engine/Api.tsx: -------------------------------------------------------------------------------- 1 | import types from "../types.json"; 2 | import { ApiBlock, Class, Function, Interface } from "../components/ApiBlocks"; 3 | import { Hr } from "../components/tags"; 4 | 5 | export function Api() { 6 | return ( 7 | 8 | {Object.values(types.interfaces) 9 | .filter((i) => i.source.startsWith("/layout")) 10 | .filter((i) => !i.name.includes("Props")) // Those are covered in styling. 11 | .map((i, index) => { 12 | return ( 13 | <> 14 |
15 | 16 | 17 | ); 18 | })} 19 | {Object.values(types.functions) 20 | .filter((f) => f.source.startsWith("/layout")) 21 | .filter((f) => !f.name.includes("Props")) // Those are covered in styling. 22 | .map((f, index) => { 23 | return ( 24 | <> 25 |
26 | 27 | 28 | ); 29 | })} 30 | {Object.values(types.classes) 31 | .filter((f) => f.source.startsWith("/layout")) 32 | .map((c, index) => { 33 | return ( 34 | <> 35 |
36 | 37 | 38 | ); 39 | })} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/layout/compose.ts: -------------------------------------------------------------------------------- 1 | import { Vec2 } from "../math/Vec2"; 2 | import { Vec4 } from "../math/Vec4"; 3 | import { intersection } from "../math/utils"; 4 | import { Display } from "./styling"; 5 | import type { Renderer } from "../renderer/Renderer"; 6 | import type { Node } from "./Node"; 7 | 8 | /** 9 | * Takes tree of nodes processed by `layout()` and calculates current positions based on 10 | * accumulated scroll values and calculates parent clipping rectangle. 11 | */ 12 | export function compose( 13 | ui: Renderer, 14 | node: Node, 15 | clipStart = new Vec2(0, 0), 16 | clipSize = new Vec2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY), 17 | scrollOffset = new Vec2(0, 0), 18 | ): void { 19 | if (node._style.display === Display.None) { 20 | return; 21 | } 22 | 23 | node._state.clipStart = clipStart; 24 | node._state.clipSize = clipSize; 25 | node._state.totalScrollX = scrollOffset.x; 26 | node._state.totalScrollY = scrollOffset.y; 27 | 28 | const nextScrollOffset = scrollOffset.add(new Vec2(node._state.scrollX, node._state.scrollY)); 29 | 30 | const clipped = intersection( 31 | new Vec4( 32 | node._state.x - scrollOffset.x, 33 | node._state.y - scrollOffset.y, 34 | node._state.clientWidth, 35 | node._state.clientHeight, 36 | ), 37 | new Vec4(clipStart.x, clipStart.y, clipSize.x, clipSize.y), 38 | ); 39 | 40 | let c = node.firstChild; 41 | while (c) { 42 | compose(ui, c, clipped.xy(), clipped.zw(), nextScrollOffset); 43 | c = c.next; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/app/components/TypeTooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Code, slugify } from "./tags"; 3 | import * as Tooltip from "@radix-ui/react-tooltip"; 4 | import type { EnumType, FieldType } from "./ApiBlocks"; 5 | import { Markdown } from "./Markdown"; 6 | 7 | type TypeTooltipProps = { 8 | enumType: EnumType; 9 | field: FieldType; 10 | }; 11 | 12 | export function TypeTooltip({ field, enumType }: TypeTooltipProps) { 13 | return ( 14 | 15 | 16 | 20 | {field.type} 21 | 22 | 23 | 24 | 25 |

{enumType.name}

26 | {enumType.description} 27 | {enumType.values.map((value) => ( 28 |
29 | {value.name} 30 | {value.description && ( 31 |
32 | {value.description} 33 |
34 | )} 35 |
36 | ))} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /docs/app/renderer/page.mdx: -------------------------------------------------------------------------------- 1 | import { Api } from "./Api"; 2 | import { getMetadata } from "../getMetadata"; 3 | export const metadata = getMetadata("Renderer"); 4 | 5 | # Renderer 6 | 7 | The renderer is responsible for rendering shapes and text on the screen. It has a very simple API: 8 | 9 | ```tsx 10 | export interface Renderer { 11 | rectangle( 12 | color: Vec4, 13 | position: Vec2, 14 | size: Vec2, 15 | corners: Vec4, 16 | borderWidth: Vec4, 17 | borderColor: Vec4, 18 | clipStart: Vec2, 19 | clipSize: Vec2, 20 | clipCorners: Vec4, 21 | ): void; 22 | 23 | render(commandEncoder: GPUCommandEncoder): void; 24 | 25 | text( 26 | text: string, 27 | position: Vec2, 28 | fontName: string, 29 | fontSize: number, 30 | color: Vec4, 31 | textAlignment: TextAlign, 32 | clipStart: Vec2, 33 | clipSize: Vec2, 34 | options?: { lineHeight?: number; maxWidth?: number }, 35 | ): void; 36 | } 37 | ``` 38 | 39 | ## Overview 40 | 41 | For each rectangle on the screen, an instance of a full-screen quad is renderered, which is then assigned to proper positions based on data stored in storage buffer. Fragment shader uses SDF to calculate border radius. 42 | 43 | ## API 44 | 45 | 46 | 47 | --- 48 | 49 | ## Text rendering 50 | 51 | Text uses SDF font rendering to display glyphs from a font atlas. See [Text Rendering](/text-rendering) page for more information. 52 | 53 | I wrote a blogpost that goes more in depth – [Drawing Text in WebGPU Using Just the Font File](https://tchayen.com/drawing-text-in-webgpu-using-just-the-font-file). 54 | -------------------------------------------------------------------------------- /src/math/Vec2.ts: -------------------------------------------------------------------------------- 1 | const EPSILON = 0.001; 2 | 3 | /** 4 | * A 2D vector. 5 | */ 6 | export class Vec2 { 7 | constructor( 8 | public readonly x: number, 9 | public readonly y: number, 10 | ) {} 11 | 12 | add(other: Vec2): Vec2 { 13 | return new Vec2(this.x + other.x, this.y + other.y); 14 | } 15 | 16 | subtract(other: Vec2): Vec2 { 17 | return new Vec2(this.x - other.x, this.y - other.y); 18 | } 19 | 20 | length(): number { 21 | return Math.sqrt(this.x * this.x + this.y * this.y); 22 | } 23 | 24 | normalize(): Vec2 { 25 | const length = this.length(); 26 | return new Vec2(this.x / length, this.y / length); 27 | } 28 | 29 | scale(scalar: number): Vec2 { 30 | return new Vec2(this.x * scalar, this.y * scalar); 31 | } 32 | 33 | cross(other: Vec2): number { 34 | return this.x * other.y - this.y * other.x; 35 | } 36 | 37 | dot(other: Vec2): number { 38 | return this.x * other.x + this.y * other.y; 39 | } 40 | 41 | distance(other: Vec2): number { 42 | return this.subtract(other).length(); 43 | } 44 | 45 | lerp(other: Vec2, t: number): Vec2 { 46 | return this.add(other.subtract(this).scale(t)); 47 | } 48 | 49 | equalsEpsilon(other: Vec2, epsilon: number): boolean { 50 | return Math.abs(this.x - other.x) < epsilon && Math.abs(this.y - other.y) < epsilon; 51 | } 52 | 53 | equals(other: Vec2): boolean { 54 | return this.equalsEpsilon(other, EPSILON); 55 | } 56 | 57 | data(): Array { 58 | return [this.x, this.y]; 59 | } 60 | 61 | toString(): string { 62 | return `(${this.x}, ${this.y})`; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/hitTest.ts: -------------------------------------------------------------------------------- 1 | import type { BaseView } from "./layout/BaseView"; 2 | import { CROSS_AXIS_SIZE } from "./consts"; 3 | import { Vec4 } from "./math/Vec4"; 4 | import { intersection as getIntersection, isInside } from "./math/utils"; 5 | import type { MouseEvent } from "./layout/eventTypes"; 6 | import { Vec2 } from "./math/Vec2"; 7 | 8 | /** 9 | * @returns whether event happened within the node's visible rectangle, including scrollbars. 10 | */ 11 | export function hitTest(node: BaseView, event: MouseEvent): boolean { 12 | const intersection = getScreenVisibleRectangle(node); 13 | return isInside(event.position, intersection); 14 | } 15 | 16 | /** 17 | * @returns rectangle of the node's visible area in screen space coordinates, including scrollbars. 18 | */ 19 | export function getScreenVisibleRectangle(node: BaseView) { 20 | const { totalScrollX, totalScrollY, clipStart, clipSize, clientHeight, clientWidth } = 21 | node._state; 22 | const nodeRectangle = new Vec4( 23 | node._state.x - totalScrollX, 24 | node._state.y - totalScrollY, 25 | clientWidth + (node._state.hasVerticalScrollbar ? CROSS_AXIS_SIZE : 0), 26 | clientHeight + (node._state.hasHorizontalScrollbar ? CROSS_AXIS_SIZE : 0), 27 | ); 28 | const boundary = new Vec4(clipStart.x, clipStart.y, clipSize.x, clipSize.y); 29 | const intersection = getIntersection(nodeRectangle, boundary); 30 | return intersection; 31 | } 32 | 33 | export function pointToNodeSpace(node: BaseView, point: Vec2): Vec2 { 34 | const { totalScrollX, totalScrollY } = node._state; 35 | return new Vec2(point.x + totalScrollX - node._state.x, point.y + totalScrollY - node._state.y); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/Queue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { Queue } from "./Queue"; 3 | 4 | describe("Queue", () => { 5 | describe("enqueue", () => { 6 | it("should add an item to the queue", () => { 7 | const queue = new Queue(); 8 | queue.enqueue(1); 9 | expect(queue.isEmpty()).toBe(false); 10 | }); 11 | }); 12 | 13 | describe("dequeue", () => { 14 | it("should remove and return the first item in the queue", () => { 15 | const queue = new Queue(); 16 | queue.enqueue(1); 17 | queue.enqueue(2); 18 | expect(queue.dequeue()).toBe(1); 19 | expect(queue.dequeue()).toBe(2); 20 | }); 21 | 22 | it("should return null if the queue is empty", () => { 23 | const queue = new Queue(); 24 | expect(queue.dequeue()).toBe(null); 25 | }); 26 | }); 27 | 28 | describe("dequeueFront", () => { 29 | it("should remove and return the last item in the queue", () => { 30 | const queue = new Queue(); 31 | queue.enqueue(1); 32 | queue.enqueue(2); 33 | expect(queue.dequeueFront()).toBe(2); 34 | expect(queue.dequeueFront()).toBe(1); 35 | }); 36 | 37 | it("should return null if the queue is empty", () => { 38 | const queue = new Queue(); 39 | expect(queue.dequeueFront()).toBe(null); 40 | }); 41 | }); 42 | 43 | describe("isEmpty", () => { 44 | it("should return true if the queue is empty", () => { 45 | const queue = new Queue(); 46 | expect(queue.isEmpty()).toBe(true); 47 | }); 48 | 49 | it("should return false if the queue is not empty", () => { 50 | const queue = new Queue(); 51 | queue.enqueue(1); 52 | expect(queue.isEmpty()).toBe(false); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /docs/app/styling/page.mdx: -------------------------------------------------------------------------------- 1 | import { Api } from "./Api"; 2 | import { getMetadata } from "../getMetadata"; 3 | export const metadata = getMetadata("Styling"); 4 | 5 | # Styling 6 | 7 | Everything to know about styling components. 8 | 9 | --- 10 | 11 | ## Edge cases 12 | 13 | Some differences (or unobvious cases) from RN and/or CSS: 14 | 15 | - Default `flexDirection` is `column` (CSS default is `row`). 16 | - Default `alignContent` is `flex-start` (CSS default is `stretch`). 17 | - Default `flexShrink` is `0` (CSS default is `1`). 18 | - `flexBasis` takes precedence over `width` and `height` if defined. 19 | - There's no `margin: auto`. 20 | - Similarly to CSS and RN, if both top and bottom (or left and right) are defined and `height` (or `width`) is _not_ defined, the element will span the distance between those two edges. 21 | - Properties with higher specificity override properties with lower specificity (in CSS it is the that order matters). 22 | In CSS `style="flex-grow: 1; flex: 2"` would use value `2` for `flex-grow` because it is defined later. Here corresponding code would use value `1` for `flex-grow` because it is more specific. Same goes for `margin`, `padding`, `borderWidth`, `gap`. 23 | - `box-sizing` is always `border-box`, which means that `width` and `height` include both `padding` and `border` (CSS default is `content-box`). 24 | 25 | --- 26 | 27 | ## Colors 28 | 29 | Colors are defined as strings, similar to CSS. 30 | 31 | Supported formats are hex (long and short variants – `#f00` and `#ff0000`), RGB, RGBA (`rgba(255, 0, 0, 0.5)`), HSL (`hsl(60 100% 50%)`, `hsl(60, 100%, 50%, 0.8)`), HSLA, HSV. 32 | 33 | RGB and HSV require adding A in the end to support alpha. In HSL it is optional. If color is not recognized, `transparent` is used and warning is logged to the console. For the most up-to-date info check `parseColor()` in the code. 34 | 35 | --- 36 | 37 | ## API 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/app/code-theme.css: -------------------------------------------------------------------------------- 1 | /* GitHub dark */ 2 | 3 | .hljs { 4 | color: var(--code-theme-main); 5 | } 6 | 7 | .hljs-doctag, 8 | .hljs-keyword, 9 | .hljs-meta .hljs-keyword, 10 | .hljs-template-tag, 11 | .hljs-template-variable, 12 | .hljs-type, 13 | .hljs-variable.language_ { 14 | color: var(--code-theme-keyword); 15 | } 16 | 17 | .hljs-title, 18 | .hljs-title.class_, 19 | .hljs-title.class_.inherited__, 20 | .hljs-title.function_ { 21 | color: var(--code-theme-title); 22 | } 23 | 24 | .hljs-attr, 25 | .hljs-attribute, 26 | .hljs-literal, 27 | .hljs-meta, 28 | .hljs-number, 29 | .hljs-operator, 30 | .hljs-selector-attr, 31 | .hljs-selector-class, 32 | .hljs-selector-id, 33 | .hljs-variable { 34 | color: var(--code-theme-number); 35 | } 36 | 37 | .hljs-meta .hljs-string, 38 | .hljs-regexp, 39 | .hljs-string { 40 | color: var(--code-theme-string); 41 | } 42 | 43 | .hljs-built_in, 44 | .hljs-symbol { 45 | color: var(--code-theme-symbol); 46 | } 47 | 48 | .hljs-code, 49 | .hljs-comment, 50 | .hljs-formula { 51 | color: var(--code-theme-comment); 52 | } 53 | 54 | .hljs-name, 55 | .hljs-quote, 56 | .hljs-selector-pseudo, 57 | .hljs-selector-tag { 58 | color: var(--code-theme-quote); 59 | } 60 | 61 | .hljs-subst { 62 | color: var(--code-theme-subst); 63 | } 64 | 65 | .hljs-section { 66 | color: var(--code-theme-section); 67 | font-weight: 700; 68 | } 69 | 70 | .hljs-bullet { 71 | color: var(--code-theme-bullet); 72 | } 73 | 74 | .hljs-emphasis { 75 | color: var(--code-theme-emphasis); 76 | font-style: italic; 77 | } 78 | 79 | .hljs-strong { 80 | color: var(--code-theme-strong); 81 | font-weight: 700; 82 | } 83 | 84 | .hljs-addition { 85 | color: var(--code-theme-addition); 86 | background-color: var(--code-theme-addition-background); 87 | } 88 | 89 | .hljs-deletion { 90 | color: var(--code-theme-deletion); 91 | background-color: var(--code-theme-deletion-background); 92 | } 93 | -------------------------------------------------------------------------------- /src/math/Vec3.ts: -------------------------------------------------------------------------------- 1 | const EPSILON = 0.001; 2 | 3 | /** 4 | * A 3-dimensional vector. 5 | */ 6 | export class Vec3 { 7 | constructor( 8 | public readonly x: number, 9 | public readonly y: number, 10 | public readonly z: number, 11 | ) {} 12 | 13 | add(other: Vec3): Vec3 { 14 | return new Vec3(this.x + other.x, this.y + other.y, this.z + other.z); 15 | } 16 | 17 | subtract(other: Vec3): Vec3 { 18 | return new Vec3(this.x - other.x, this.y - other.y, this.z - other.z); 19 | } 20 | 21 | length(): number { 22 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); 23 | } 24 | 25 | normalize(): Vec3 { 26 | const length = this.length(); 27 | return new Vec3(this.x / length, this.y / length, this.z / length); 28 | } 29 | 30 | scale(scalar: number): Vec3 { 31 | return new Vec3(this.x * scalar, this.y * scalar, this.z * scalar); 32 | } 33 | 34 | cross(other: Vec3): Vec3 { 35 | return new Vec3( 36 | this.y * other.z - this.z * other.y, 37 | this.z * other.x - this.x * other.z, 38 | this.x * other.y - this.y * other.x, 39 | ); 40 | } 41 | 42 | dot(other: Vec3): number { 43 | return this.x * other.x + this.y * other.y + this.z * other.z; 44 | } 45 | 46 | distance(other: Vec3): number { 47 | return this.subtract(other).length(); 48 | } 49 | 50 | lerp(other: Vec3, t: number): Vec3 { 51 | return this.add(other.subtract(this).scale(t)); 52 | } 53 | 54 | equalsEpsilon(other: Vec3, epsilon: number): boolean { 55 | return ( 56 | Math.abs(this.x - other.x) < epsilon && 57 | Math.abs(this.y - other.y) < epsilon && 58 | Math.abs(this.z - other.z) < epsilon 59 | ); 60 | } 61 | 62 | equals(other: Vec3): boolean { 63 | return this.equalsEpsilon(other, EPSILON); 64 | } 65 | 66 | toString(): string { 67 | return `(${this.x}, ${this.y}, ${this.z})`; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "description": "Fancy Next.js app", 5 | "scripts": { 6 | "dev": "next --port 4141", 7 | "build": "bun run prepare-sandpack && bun scripts/extractTypeScript.ts && next build", 8 | "prepare-sandpack": "cd .. && bun i && bun run prepare-sandpack && cd docs", 9 | "start": "next start", 10 | "lint": "eslint .", 11 | "ci": "bun run lint && bun run tsc && bun run build", 12 | "build:static": "next build && next out" 13 | }, 14 | "dependencies": { 15 | "@codesandbox/sandpack-react": "^2.11.3", 16 | "@mdx-js/loader": "^3.0.0", 17 | "@mdx-js/mdx": "^3.0.0", 18 | "@mdx-js/react": "^3.0.0", 19 | "@next/mdx": "^14.1.0", 20 | "@radix-ui/colors": "^3.0.0", 21 | "@radix-ui/react-collapsible": "^1.0.3", 22 | "@radix-ui/react-dialog": "^1.0.5", 23 | "@radix-ui/react-tabs": "^1.0.4", 24 | "@radix-ui/react-tooltip": "^1.0.7", 25 | "cheerio": "^1.0.0-rc.12", 26 | "date-fns": "^3.0.6", 27 | "flexsearch": "^0.7.43", 28 | "highlight.js": "^11.9.0", 29 | "lodash": "^4.17.21", 30 | "next": "^14.1.0", 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "rehype-mdx-code-props": "^2.0.0", 34 | "unist-util-visit": "^5.0.0" 35 | }, 36 | "devDependencies": { 37 | "@total-typescript/ts-reset": "^0.5.1", 38 | "@types/flexsearch": "^0.7.6", 39 | "@types/lodash": "^4.14.202", 40 | "@types/mdx": "^2.0.10", 41 | "@types/react": "18.2.48", 42 | "autoprefixer": "^10.4.17", 43 | "eslint-config-next": "^14.1.0", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "postcss": "^8.4.33", 46 | "prettier": "^3.2.4", 47 | "prettier-plugin-tailwindcss": "^0.5.11", 48 | "remark-gfm": "^4.0.0", 49 | "tailwind-merge": "^2.2.0", 50 | "tailwindcss": "^3.4.1", 51 | "typescript": "^5.3.3", 52 | "webpack": "^5.89.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/math/Mat4.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | 3 | import { Mat4 } from "./Mat4"; 4 | import { Vec3 } from "./Vec3"; 5 | 6 | describe("Mat4", () => { 7 | it("inverse is correct", () => { 8 | const m = new Mat4([2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2]); 9 | 10 | const i = m.invert(); 11 | const multipled = m.multiply(i); 12 | const expected = Mat4.identity(); 13 | 14 | expect(multipled).toEqual(expected); 15 | }); 16 | 17 | it("should correctly transpose the matrix", () => { 18 | const m = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); 19 | 20 | const result = m.transpose(); 21 | 22 | expect(result.data).toEqual([1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16]); 23 | }); 24 | 25 | it("should correctly multiply two matrices", () => { 26 | const m1 = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); 27 | const m2 = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); 28 | 29 | const result = m1.multiply(m2); 30 | 31 | expect(result.data).toEqual([ 32 | 90, 100, 110, 120, 202, 228, 254, 280, 314, 356, 398, 440, 426, 484, 542, 600, 33 | ]); 34 | }); 35 | 36 | it("should return a correctly oriented camera matrix", () => { 37 | const cameraPosition = new Vec3(1, 2, 3); 38 | const target = new Vec3(0, 0, 0); 39 | const up = new Vec3(0, 1, 0); 40 | 41 | const result = Mat4.lookAt(cameraPosition, target, up); 42 | 43 | const expected = new Mat4([ 44 | 0.948_683_298_050_513_9, -0.169_030_850_945_703_33, 0.267_261_241_912_424_4, 0, 0, 45 | 0.845_154_254_728_516_7, 0.534_522_483_824_848_8, 0, -0.316_227_766_016_837_94, 46 | -0.507_092_552_837_11, 0.801_783_725_737_273_2, 0, -1.110_223_024_625_156_5e-16, 47 | -2.220_446_049_250_313e-16, -3.741_657_386_773_941_3, 1, 48 | ]); 49 | 50 | expect(result.data).toEqual(expected.data); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/math/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Vec2 } from "./Vec2"; 2 | import { Vec4 } from "./Vec4"; 3 | 4 | /** 5 | * Makes sure that the given value is within the given range using combination 6 | * of min() and max(). 7 | */ 8 | export function clamp(value: number, min: number, max: number): number { 9 | return Math.max(Math.min(value, max), min); 10 | } 11 | 12 | /** 13 | * Performs a linear interpolation between two values. 14 | */ 15 | export function lerp(a: number, b: number, t: number): number { 16 | return a + (b - a) * t; 17 | } 18 | 19 | /** 20 | * Performs a smooth interpolation between two values. 21 | * `t` should be in the range [0, 1]. 22 | */ 23 | export function smoothstep(a: number, b: number, t: number): number { 24 | t = clamp((t - a) / (b - a), 0, 1); 25 | return t * t * (3 - 2 * t); 26 | } 27 | 28 | /** 29 | * Converts degrees to radians. 30 | */ 31 | export function toRadians(degrees: number): number { 32 | return (degrees * Math.PI) / 180; 33 | } 34 | 35 | /** 36 | * Converts radians to degrees. 37 | */ 38 | export function toDegrees(radians: number): number { 39 | return (radians * 180) / Math.PI; 40 | } 41 | 42 | /** 43 | * Returns the next power of two that is greater than or equal to the given 44 | * value. 45 | */ 46 | export function nextPowerOfTwo(value: number): number { 47 | return Math.pow(2, Math.ceil(Math.log(value) / Math.log(2))); 48 | } 49 | 50 | /** 51 | * Returns intersection of two rectangles. If there is no intersection, returns a Vec4(0, 0, 0, 0). 52 | */ 53 | export function intersection(a: Vec4, b: Vec4): Vec4 { 54 | const x = Math.max(a.x, b.x); 55 | const y = Math.max(a.y, b.y); 56 | const width = Math.min(a.x + a.z, b.x + b.z) - x; 57 | const height = Math.min(a.y + a.w, b.y + b.w) - y; 58 | 59 | if (width <= 0 || height <= 0) { 60 | return new Vec4(0, 0, 0, 0); 61 | } 62 | 63 | return new Vec4(x, y, width, height); 64 | } 65 | 66 | /** 67 | * Checks if the given point is inside the given rectangle. 68 | */ 69 | export function isInside(point: Vec2, rectangle: Vec4): boolean { 70 | return ( 71 | point.x >= rectangle.x && 72 | point.x <= rectangle.x + rectangle.z && 73 | point.y >= rectangle.y && 74 | point.y <= rectangle.y + rectangle.w 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release package 2 | on: 3 | workflow_run: 4 | branches: 5 | - main 6 | workflows: 7 | - CI actions 8 | types: 9 | - completed 10 | jobs: 11 | check-commit: 12 | runs-on: ubuntu-latest 13 | # Skip if the workflow run for tests, linting etc. is not successful. Without this, the release 14 | # will be triggered after the previous workflow run even if it failed. 15 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 16 | outputs: 17 | skip: ${{ steps.commit-message.outputs.skip }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | # Check if the commit message is a release commit. Without this, there will be an infinite 22 | # loop of releases. 23 | - name: Get commit message 24 | id: commit-message 25 | run: | 26 | MESSAGE=$(git log --format=%B -n 1 $(git log -1 --pretty=format:"%h")) 27 | 28 | if [[ $MESSAGE == "chore: release "* ]]; then 29 | echo "skip=true" >> $GITHUB_OUTPUT 30 | fi 31 | release: 32 | runs-on: ubuntu-latest 33 | needs: check-commit 34 | # Skip if the commit message is a release commit. 35 | if: ${{ needs.check-commit.outputs.skip != 'true' }} 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v3 39 | with: 40 | # This is needed to generate the changelog from commit messages. 41 | fetch-depth: 0 42 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 43 | - name: Setup Node.js 44 | uses: actions/setup-node@v3 45 | - name: Install bun 46 | uses: oven-sh/setup-bun@v1 47 | - name: Install dependencies 48 | run: bun i --no-save 49 | shell: bash 50 | - name: Configure Git 51 | run: | 52 | git config user.name "${GITHUB_ACTOR}" 53 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 54 | - name: Debug 55 | run: | 56 | git status 57 | - name: Create release 58 | run: | 59 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 60 | bun run release-it --ci 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 63 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 64 | -------------------------------------------------------------------------------- /src/utils/parseColor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | 3 | import { parseColor } from "./parseColor"; 4 | import { Vec4 } from "../math/Vec4"; 5 | 6 | describe("parseColor", () => { 7 | describe("hex colors", () => { 8 | it("should parse hex color", () => { 9 | expect(parseColor("#ff0000")).toEqual(new Vec4(1, 0, 0, 1)); 10 | }); 11 | 12 | it("should parse with alpha", () => { 13 | expect(parseColor("#ff0000aa")).toEqual(new Vec4(1, 0, 0, 0.666_666_666_666_666_6)); 14 | }); 15 | 16 | it("should parse short hex", () => { 17 | expect(parseColor("#f00")).toEqual(new Vec4(1, 0, 0, 1)); 18 | }); 19 | 20 | it("should reject other length of hex", () => { 21 | expect(() => parseColor("#ff00")).toThrow(); 22 | }); 23 | }); 24 | 25 | describe("RGB colors", () => { 26 | it("should parse RGB", () => { 27 | expect(parseColor("rgb(255, 0, 0)")).toEqual(new Vec4(1, 0, 0, 1)); 28 | }); 29 | 30 | it("should parse RGB without commas", () => { 31 | expect(parseColor("rgb(255 0 0)")).toEqual(new Vec4(1, 0, 0, 1)); 32 | }); 33 | 34 | it("should parse RGBA", () => { 35 | expect(parseColor("rgba(255, 0, 0, 0.5)")).toEqual(new Vec4(1, 0, 0, 0.5)); 36 | }); 37 | 38 | it("should reject RGB with alpha", () => { 39 | expect(() => parseColor("rgb(255, 0, 0, 0.5)")).toThrow(); 40 | }); 41 | }); 42 | 43 | describe("HSL colors", () => { 44 | it("should parse", () => { 45 | expect(parseColor("hsl(60, 100%, 50%)")).toEqual(new Vec4(1, 1, 0, 1)); 46 | }); 47 | 48 | it("should parse without commas", () => { 49 | expect(parseColor("hsl(60 100% 50%)")).toEqual(new Vec4(1, 1, 0, 1)); 50 | }); 51 | 52 | it("should parse with alpha", () => { 53 | expect(parseColor("hsl(60, 100%, 50%, 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8)); 54 | }); 55 | 56 | it("should parse HSLA", () => { 57 | expect(parseColor("hsla(60, 100%, 50%, 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8)); 58 | }); 59 | 60 | it("should parse HSLA without commas", () => { 61 | expect(parseColor("hsla(60 100% 50% 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8)); 62 | }); 63 | 64 | it("should parse HSLA with a slash", () => { 65 | expect(parseColor("hsla(60 100% 50% / 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8)); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /docs/app/text-rendering/page.mdx: -------------------------------------------------------------------------------- 1 | import { Api } from "./Api"; 2 | import { getMetadata } from "../getMetadata"; 3 | export const metadata = getMetadata("Text Rendering"); 4 | 5 | # Text Rendering 6 | 7 | Everything about the process of getting text to the user's screen. 8 | 9 | --- 10 | 11 | ## Overview 12 | 13 | Text rendering is based on SDF (Signed Distance Field) font atlas approach. This means that for every font to be rendered, the TTF file must be parsed, glyph information extracted and then the font atlas needs to be generated. The basic and default approach is to use HTML Canvas API to prepare it. 14 | 15 | --- 16 | 17 | ## Font lookups 18 | 19 | All the information about text that is later needed is bundled in what I call `Lookups`: 20 | 21 | ```ts 22 | type Lookups = { 23 | atlas: { 24 | fontSize: number; 25 | height: number; 26 | positions: Array; 27 | sizes: Array; 28 | width: number; 29 | }; 30 | fonts: Array<{ 31 | ascender: number; 32 | buffer: ArrayBuffer; 33 | capHeight: number; 34 | glyphs: Map; 35 | kern: KerningFunction; 36 | name: string; 37 | ttf: TTF; 38 | unitsPerEm: number; 39 | }>; 40 | uvs: Map; 41 | }; 42 | ``` 43 | 44 | And this is how it is generated: 45 | 46 | ```ts 47 | // Alphabet is a string containing all characters that should be included in the atlas. 48 | const alphabet = "AaBbCc…"; 49 | 50 | const [interTTF, interBoldTTF] = await Promise.all( 51 | ["/Inter.ttf", "/Inter-SemiBold.ttf"].map((url) => 52 | fetch(url).then((response) => response.arrayBuffer()), 53 | ), 54 | ); 55 | 56 | const lookups = prepareLookups( 57 | // Declare all fonts that should be included. 58 | [ 59 | { 60 | buffer: interTTF, 61 | name: "Inter", 62 | ttf: parseTTF(interTTF), 63 | }, 64 | { 65 | buffer: interBoldTTF, 66 | name: "InterBold", 67 | ttf: parseTTF(interBoldTTF), 68 | }, 69 | ], 70 | // Render at 150px size. The size of the font atlas will be determined by packShelves() algorithm. 71 | { alphabet, fontSize: 150 }, 72 | ); 73 | 74 | // Generate _one_ font atlas texture for all fonts. In future it would make sense to allow splitting for projects that use many fonts. 75 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true }); 76 | ``` 77 | 78 | ## API 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Body } from "./components/Body"; 3 | import { mauveDark } from "@radix-ui/colors"; 4 | import { Hr } from "./components/tags"; 5 | import "./code-theme.css"; 6 | 7 | export default function RootLayout({ children }: { children: React.ReactNode }) { 8 | return ( 9 | 10 | 11 |