From f4492f28efb997b8cf0044963d642a6247333e95 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 15 Feb 2026 21:09:07 -0600 Subject: [PATCH] feat: CodeMirror 6 with oxocarbon themes --- docs/spec.md | 2 +- fixtures/markdown/basic.outline.json | 5 +- package.json | 10 + pnpm-lock.yaml | 223 ++++++++++++++++++ src-tauri/tauri.conf.json | 21 +- src/App.tsx | 53 ++--- src/__tests__/Editor.test.tsx | 121 ++++++++++ src/__tests__/useEditor.test.ts | 324 +++++++++++++++++++++++++++ src/components/Editor.tsx | 228 +++++++++++++++++++ src/hooks/useEditor.ts | 148 ++++++++++++ src/ports.ts | 48 ++++ src/test/setup.ts | 130 ++++++++++- src/themes/oxocarbon-dark.ts | 190 ++++++++++++++++ src/themes/oxocarbon-light.ts | 189 ++++++++++++++++ src/usePorts.ts | 2 + tsconfig.json | 2 - 16 files changed, 1635 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/Editor.test.tsx create mode 100644 src/__tests__/useEditor.test.ts create mode 100644 src/components/Editor.tsx create mode 100644 src/hooks/useEditor.ts create mode 100644 src/themes/oxocarbon-dark.ts create mode 100644 src/themes/oxocarbon-light.ts diff --git a/docs/spec.md b/docs/spec.md index 58e5e8a..f092e9f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -44,7 +44,7 @@ Tauri's **dialog plugin** can open file/directory selectors; selected paths are ### Editor -- Split view optional (preview later; not required) +- Split view - Focus modes: - typewriter scroll - distraction-free diff --git a/fixtures/markdown/basic.outline.json b/fixtures/markdown/basic.outline.json index c55fd16..21667a0 100644 --- a/fixtures/markdown/basic.outline.json +++ b/fixtures/markdown/basic.outline.json @@ -7,8 +7,5 @@ { "level": 2, "text": "Section 2" } ], "word_count": 42, - "task_items": { - "total": 2, - "completed": 1 - } + "task_items": { "total": 2, "completed": 1 } } diff --git a/package.json b/package.json index 450c1dd..3a2dc0d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,16 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.1", + "@codemirror/lint": "^6.9.4", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.14", + "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.6.3", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56914f..4611e47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,36 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.0 + version: 6.20.0 + '@codemirror/commands': + specifier: ^6.10.2 + version: 6.10.2 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 + '@codemirror/language': + specifier: ^6.12.1 + version: 6.12.1 + '@codemirror/lint': + specifier: ^6.9.4 + version: 6.9.4 + '@codemirror/search': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.39.14 + version: 6.39.14 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 + '@lezer/markdown': + specifier: ^1.6.3 + version: 1.6.3 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -179,6 +209,39 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/language@6.12.1': + resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + + '@codemirror/lint@6.9.4': + resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + + '@codemirror/view@6.39.14': + resolution: {integrity: sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==} + '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -446,6 +509,30 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/css@1.3.0': + resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -894,6 +981,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1259,6 +1349,9 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1395,6 +1488,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -1570,6 +1666,92 @@ snapshots: dependencies: css-tree: 3.1.0 + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.2': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.0 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.0 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.4 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + '@lezer/markdown': 1.6.3 + + '@codemirror/language@6.12.1': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.4': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + crelt: 1.0.6 + + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + crelt: 1.0.6 + + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.39.14': + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -1724,6 +1906,41 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.1': {} + + '@lezer/css@1.3.0': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + + '@marijn/find-cluster-break@1.0.2': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -2103,6 +2320,8 @@ snapshots: convert-source-map@2.0.0: {} + crelt@1.0.6: {} + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -2469,6 +2688,8 @@ snapshots: dependencies: min-indent: 1.0.1 + style-mod@4.1.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2568,6 +2789,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0fae7f4..6b882fc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,27 +9,10 @@ "beforeBuildCommand": "pnpm build", "frontendDist": "../dist" }, - "app": { - "windows": [ - { - "title": "writer", - "width": 800, - "height": 600 - } - ], - "security": { - "csp": null - } - }, + "app": { "windows": [{ "title": "writer", "width": 800, "height": 600 }], "security": { "csp": null } }, "bundle": { "active": true, "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] } } diff --git a/src/App.tsx b/src/App.tsx index f070cfa..5dc0efc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,40 +1,31 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useState } from "react"; +import { Editor } from "./components/Editor"; +import { useEditor } from "./hooks/useEditor"; import "./App.css"; function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); - - async function greet() { - setGreetMsg(await invoke("greet", { name })); - } + const { model, dispatch } = useEditor(); return ( -
-

Welcome to Tauri + React

- -
- - Vite logo - - - Tauri logo - - Logo +
+
+

Writer

+
+ {model.saveStatus === "Saving" && "Saving..."} + {model.saveStatus === "Saved" && "Saved"} + {model.saveStatus === "Dirty" && "Unsaved changes"} + {model.saveStatus === "Error" && "Error saving"} + {model.saveStatus === "Idle" && "Ready"} +
+
+
+ dispatch({ type: "EditorChanged", text })} + onSave={() => dispatch({ type: "SaveRequested" })} + onCursorMove={(line, column) => dispatch({ type: "CursorMoved", line, column })} + onSelectionChange={(from, to) => dispatch({ type: "SelectionChanged", from, to })} />
-

Click on the Tauri, Vite, and React logos to learn more.

- -
{ - e.preventDefault(); - greet(); - }}> - setName(e.currentTarget.value)} placeholder="Enter a name..." /> - -
-

{greetMsg}

); } diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx new file mode 100644 index 0000000..0eb7243 --- /dev/null +++ b/src/__tests__/Editor.test.tsx @@ -0,0 +1,121 @@ +/** + * Tests for Editor component + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Editor } from "../components/Editor"; + +describe("Editor", () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("should render editor container", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toBeInTheDocument(); + }); + + it("should apply custom className", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveClass("custom-editor"); + }); + + it("should set data-theme attribute", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveAttribute("data-theme", "light"); + }); + + it("should set data-ready attribute when editor is initialized", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveAttribute("data-ready", "true"); + }); + }); + + describe("initial content", () => { + it("should render with empty text by default", () => { + const { container } = render(); + const editorContent = container.querySelector(".cm-content"); + expect(editorContent).toBeInTheDocument(); + }); + + it("should render with initial text", () => { + const { container } = render(); + const editorContent = container.querySelector(".cm-content"); + expect(editorContent).toBeInTheDocument(); + }); + }); + + describe("theme switching", () => { + it("should support dark theme", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveAttribute("data-theme", "dark"); + }); + + it("should support light theme", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveAttribute("data-theme", "light"); + }); + + it("should default to dark theme", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toHaveAttribute("data-theme", "dark"); + }); + }); + + describe("disabled state", () => { + it("should render in enabled state by default", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toBeInTheDocument(); + }); + + it("should render in disabled state", () => { + render(); + const container = screen.getByTestId("editor-container"); + expect(container).toBeInTheDocument(); + }); + }); + + describe("callbacks", () => { + it("should call onChange when content changes", async () => { + const { container } = render(); + const content = container.querySelector(".cm-content"); + + if (content) { + fireEvent.input(content, { target: { textContent: "New text" } }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }, { timeout: 100 }); + } + }); + + it("should debounce onChange calls", async () => { + const { container } = render(); + const content = container.querySelector(".cm-content"); + + if (content) { + fireEvent.input(content, { target: { textContent: "Text 1" } }); + fireEvent.input(content, { target: { textContent: "Text 2" } }); + fireEvent.input(content, { target: { textContent: "Text 3" } }); + + expect(mockOnChange).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }, { timeout: 200 }); + } + }); + }); +}); diff --git a/src/__tests__/useEditor.test.ts b/src/__tests__/useEditor.test.ts new file mode 100644 index 0000000..a7db11b --- /dev/null +++ b/src/__tests__/useEditor.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for useEditor hook + */ + +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { initialEditorModel, updateEditor, useEditor } from "../hooks/useEditor"; +import { AppError } from "../ports"; + +describe("useEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("initial state", () => { + it("should have initial model with default values", () => { + const { result } = renderHook(() => useEditor()); + + expect(result.current.model).toEqual(initialEditorModel); + }); + + it("should have empty text initially", () => { + const { result } = renderHook(() => useEditor()); + + expect(result.current.model.text).toBe(""); + }); + + it("should have Idle save status initially", () => { + const { result } = renderHook(() => useEditor()); + + expect(result.current.model.saveStatus).toBe("Idle"); + }); + + it("should have cursor at line 1, column 0 initially", () => { + const { result } = renderHook(() => useEditor()); + + expect(result.current.model.cursorLine).toBe(1); + expect(result.current.model.cursorColumn).toBe(0); + }); + }); + + describe("EditorChanged", () => { + it("should update text and set status to Dirty when text changes", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "EditorChanged", text: "New text" }); + }); + + expect(result.current.model.text).toBe("New text"); + expect(result.current.model.saveStatus).toBe("Dirty"); + }); + + it("should not update if text is the same", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "EditorChanged", text: "Text" }); + }); + + const firstUpdate = result.current.model; + + act(() => { + result.current.dispatch({ type: "EditorChanged", text: "Text" }); + }); + + expect(result.current.model).toBe(firstUpdate); + }); + + it("should keep Dirty status if already dirty", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "EditorChanged", text: "Text 1" }); + }); + + act(() => { + result.current.dispatch({ type: "EditorChanged", text: "Text 2" }); + }); + + expect(result.current.model.saveStatus).toBe("Dirty"); + }); + }); + + describe("SaveRequested", () => { + it("should not save if no docRef is set", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "SaveRequested" }); + }); + + expect(result.current.model.saveStatus).toBe("Idle"); + }); + + it("should set status to Saving when save is requested", async () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ + type: "DocOpened", + doc: { + text: "Hello", + meta: { location_id: 1, rel_path: "test.md", title: "Test", updated_at: "2024-01-01", word_count: 1 }, + }, + }); + }); + + act(() => { + result.current.dispatch({ type: "SaveRequested" }); + }); + + expect(result.current.model.saveStatus).toBe("Saving"); + }); + + it("should not save if already saving", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ + type: "DocOpened", + doc: { + text: "Hello", + meta: { location_id: 1, rel_path: "test.md", title: "Test", updated_at: "2024-01-01", word_count: 1 }, + }, + }); + }); + + act(() => { + result.current.dispatch({ type: "SaveRequested" }); + }); + + act(() => { + result.current.dispatch({ type: "SaveRequested" }); + }); + + expect(result.current.model.saveStatus).toBe("Saving"); + }); + }); + + describe("SaveFinished", () => { + it("should set status to Saved on successful save", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "SaveFinished", success: true }); + }); + + expect(result.current.model.saveStatus).toBe("Saved"); + }); + + it("should set status to Error on failed save", () => { + const { result } = renderHook(() => useEditor()); + + const error: AppError = { code: "IO_ERROR", message: "Save failed" }; + + act(() => { + result.current.dispatch({ type: "SaveFinished", success: false, error }); + }); + + expect(result.current.model.saveStatus).toBe("Error"); + expect(result.current.model.error).toEqual(error); + }); + + it("should clear error on successful save", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ + type: "SaveFinished", + success: false, + error: { code: "IO_ERROR", message: "Error" }, + }); + }); + + act(() => { + result.current.dispatch({ type: "SaveFinished", success: true }); + }); + + expect(result.current.model.error).toBeNull(); + }); + }); + + describe("DocOpened", () => { + it("should update text and meta when doc is opened", () => { + const { result } = renderHook(() => useEditor()); + + const docContent = { + text: "# Test Document", + meta: { + location_id: 1, + rel_path: "docs/test.md", + title: "Test Document", + updated_at: "2024-01-15T10:00:00Z", + word_count: 100, + }, + }; + + act(() => { + result.current.dispatch({ type: "DocOpened", doc: docContent }); + }); + + expect(result.current.model.text).toBe("# Test Document"); + expect(result.current.model.docRef).toEqual({ location_id: 1, rel_path: "docs/test.md" }); + expect(result.current.model.saveStatus).toBe("Saved"); + expect(result.current.model.isLoading).toBe(false); + }); + }); + + describe("CursorMoved", () => { + it("should update cursor position", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "CursorMoved", line: 5, column: 10 }); + }); + + expect(result.current.model.cursorLine).toBe(5); + expect(result.current.model.cursorColumn).toBe(10); + }); + + it("should handle multiple cursor moves", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "CursorMoved", line: 3, column: 5 }); + }); + + act(() => { + result.current.dispatch({ type: "CursorMoved", line: 10, column: 0 }); + }); + + expect(result.current.model.cursorLine).toBe(10); + expect(result.current.model.cursorColumn).toBe(0); + }); + }); + + describe("SelectionChanged", () => { + it("should update selection", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "SelectionChanged", from: 10, to: 25 }); + }); + + expect(result.current.model.selectionFrom).toBe(10); + expect(result.current.model.selectionTo).toBe(25); + }); + + it("should handle empty selection", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ type: "SelectionChanged", from: 10, to: null }); + }); + + expect(result.current.model.selectionFrom).toBe(10); + expect(result.current.model.selectionTo).toBeNull(); + }); + }); + + describe("convenience methods", () => { + it("should dispatch OpenDocRequested when openDoc is called", () => { + const { result } = renderHook(() => useEditor()); + + const docRef = { location_id: 1, rel_path: "test.md" }; + + act(() => { + result.current.openDoc(docRef); + }); + + expect(result.current.model.isLoading).toBe(true); + }); + + it("should dispatch SaveRequested when saveDoc is called", () => { + const { result } = renderHook(() => useEditor()); + + act(() => { + result.current.dispatch({ + type: "DocOpened", + doc: { + text: "Hello", + meta: { location_id: 1, rel_path: "test.md", title: "Test", updated_at: "2024-01-01", word_count: 1 }, + }, + }); + }); + + act(() => { + result.current.saveDoc(); + }); + + expect(result.current.model.saveStatus).toBe("Saving"); + }); + }); +}); + +describe("updateEditor", () => { + it("should return same model and None command for unknown message types", () => { + const model = initialEditorModel; + const [newModel, cmd] = updateEditor(model, { type: "Unknown" as any }); + + expect(newModel).toBe(model); + expect(cmd.type).toBe("None"); + }); + + it("should return command for OpenDocRequested", () => { + const model = initialEditorModel; + const docRef = { location_id: 1, rel_path: "test.md" }; + + const [newModel, cmd] = updateEditor(model, { type: "OpenDocRequested", docRef }); + + expect(newModel.isLoading).toBe(true); + expect(cmd.type).toBe("Invoke"); + }); + + it("should return command for SaveRequested when docRef exists", () => { + const model = { ...initialEditorModel, docRef: { location_id: 1, rel_path: "test.md" }, text: "Content" }; + + const [newModel, cmd] = updateEditor(model, { type: "SaveRequested" }); + + expect(newModel.saveStatus).toBe("Saving"); + expect(cmd.type).toBe("Invoke"); + }); +}); diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx new file mode 100644 index 0000000..67781a4 --- /dev/null +++ b/src/components/Editor.tsx @@ -0,0 +1,228 @@ +/** + * Editor Component + * + * A CodeMirror 6-based Markdown editor integrated with Elm-style state management. + * + * Features: + * - Markdown syntax highlighting + * - Line and selection persistence + * - Undo/redo support + * - Debounced change events + * - Oxocarbon Dark & Light themes + */ + +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { markdown } from "@codemirror/lang-markdown"; +import { EditorState as CMEditorState } from "@codemirror/state"; +import { EditorView, keymap, ViewUpdate } from "@codemirror/view"; +import { lineNumbers } from "@codemirror/view"; +import { useEffect, useRef, useState } from "react"; + +import { oxocarbonDark } from "../themes/oxocarbon-dark"; +import { oxocarbonLight } from "../themes/oxocarbon-light"; + +export type EditorTheme = "dark" | "light"; + +export interface EditorProps { + /** Initial text content */ + initialText?: string; + /** Current theme */ + theme?: EditorTheme; + /** Whether the editor is disabled */ + disabled?: boolean; + /** Placeholder text when empty */ + placeholder?: string; + /** Debounce delay in ms for change events */ + debounceMs?: number; + /** Callback when text changes */ + onChange?: (text: string) => void; + /** Callback when save is triggered (Ctrl/Cmd+S) */ + onSave?: () => void; + /** Callback when cursor position changes */ + onCursorMove?: (line: number, column: number) => void; + /** Callback when selection changes */ + onSelectionChange?: (from: number, to: number | null) => void; + /** Additional CSS class */ + className?: string; +} + +/** + * Debounces a function call. + */ +function debounce(fn: (text: string) => void, delay: number): (text: string) => void { + let timeoutId: ReturnType | null = null; + return (text: string) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(text), delay); + }; +} + +/** + * CodeMirror 6 Markdown Editor component. + * + * This component wraps CodeMirror in a React-friendly interface while + * maintaining performance by avoiding full re-renders on every keystroke. + */ +export function Editor( + { + initialText = "", + theme = "dark", + disabled = false, + placeholder, + debounceMs = 500, + onChange, + onSave, + onCursorMove, + onSelectionChange, + className = "", + }: EditorProps, +) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const [isReady, setIsReady] = useState(false); + + const themeExtension = theme === "dark" ? oxocarbonDark : oxocarbonLight; + + useEffect(() => { + if (!containerRef.current) return; + + const debouncedOnChange = debounce((text: string) => { + onChange?.(text); + }, debounceMs); + + const updateListener = EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + debouncedOnChange(update.state.doc.toString()); + } + + if (update.selectionSet) { + const selection = update.state.selection.main; + const from = selection.from; + const to = selection.empty ? null : selection.to; + onSelectionChange?.(from, to); + } + + if (update.selectionSet || update.docChanged) { + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + const column = pos - line.from; + onCursorMove?.(line.number, column); + } + }); + + const saveKeymap = keymap.of([{ + key: "Mod-s", + preventDefault: true, + run: () => { + onSave?.(); + return true; + }, + }]); + + const customKeymap = keymap.of([...defaultKeymap, ...historyKeymap]); + + const state = CMEditorState.create({ + doc: initialText, + extensions: [ + lineNumbers(), + history(), + markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true }), + themeExtension, + updateListener, + customKeymap, + saveKeymap, + EditorView.editable.of(!disabled), + placeholder ? EditorView.theme({ ".cm-placeholder": { color: "#888" } }) : [], + ], + }); + + const view = new EditorView({ state, parent: containerRef.current }); + + viewRef.current = view; + setIsReady(true); + + return () => { + view.destroy(); + viewRef.current = null; + setIsReady(false); + }; + }, [initialText, theme, disabled, placeholder, debounceMs, onChange, onSave, onCursorMove, onSelectionChange]); + + useEffect(() => { + if (viewRef.current) { + const currentText = viewRef.current.state.doc.toString(); + viewRef.current.destroy(); + + const debouncedOnChange = debounce((text: string) => { + onChange?.(text); + }, debounceMs); + + const updateListener = EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + debouncedOnChange(update.state.doc.toString()); + } + + if (update.selectionSet) { + const selection = update.state.selection.main; + const from = selection.from; + const to = selection.empty ? null : selection.to; + onSelectionChange?.(from, to); + } + + if (update.selectionSet || update.docChanged) { + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + const column = pos - line.from; + onCursorMove?.(line.number, column); + } + }); + + const saveKeymap = keymap.of([{ + key: "Mod-s", + preventDefault: true, + run: () => { + onSave?.(); + return true; + }, + }]); + + const customKeymap = keymap.of([...defaultKeymap, ...historyKeymap]); + + const state = CMEditorState.create({ + doc: currentText, + extensions: [ + lineNumbers(), + history(), + markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true }), + themeExtension, + updateListener, + customKeymap, + saveKeymap, + EditorView.editable.of(!disabled), + placeholder ? EditorView.theme({ ".cm-placeholder": { color: "#888" } }) : [], + ], + }); + + viewRef.current = new EditorView({ state, parent: containerRef.current! }); + } + }, [theme, disabled, placeholder, debounceMs, onChange, onSave, onCursorMove, onSelectionChange, themeExtension]); + + const focus = () => { + viewRef.current?.focus(); + }; + + return ( +
+ ); +} + +export default Editor; + +import { markdownLanguage } from "@codemirror/lang-markdown"; diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts new file mode 100644 index 0000000..cb671b5 --- /dev/null +++ b/src/hooks/useEditor.ts @@ -0,0 +1,148 @@ +/** + * useEditor hook + * + * Provides Elm-style state management for the Editor component. + * + * Features: + * - Manages editor state (text, save status, cursor position) + * - Handles debounced save requests + * - Integrates with the ports system for backend communication + */ + +import { useCallback, useState } from "react"; +import { AppError, Cmd, DocContent, docOpen, DocRef, docSave, none, runCmd, SaveStatus } from "../ports"; + +export interface EditorModel { + docRef: DocRef | null; + text: string; + saveStatus: SaveStatus; + cursorLine: number; + cursorColumn: number; + selectionFrom: number | null; + selectionTo: number | null; + isLoading: boolean; + error: AppError | null; +} + +export const initialEditorModel: EditorModel = { + docRef: null, + text: "", + saveStatus: "Idle", + cursorLine: 1, + cursorColumn: 0, + selectionFrom: null, + selectionTo: null, + isLoading: false, + error: null, +}; + +export type EditorMsg = + | { type: "EditorChanged"; text: string } + | { type: "SaveRequested" } + | { type: "SaveFinished"; success: boolean; error?: AppError } + | { type: "DocOpened"; doc: DocContent } + | { type: "OpenDocRequested"; docRef: DocRef } + | { type: "DocOpenFinished"; success: boolean; error?: AppError } + | { type: "CursorMoved"; line: number; column: number } + | { type: "SelectionChanged"; from: number; to: number | null }; + +export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, Cmd] { + switch (msg.type) { + case "EditorChanged": { + if (model.text === msg.text) { + return [model, none]; + } + return [{ ...model, text: msg.text, saveStatus: model.saveStatus === "Saving" ? "Saving" : "Dirty" }, none]; + } + + case "SaveRequested": { + if (!model.docRef || model.saveStatus === "Saving") { + return [model, none]; + } + return [ + { ...model, saveStatus: "Saving" }, + docSave( + model.docRef, + model.text, + () => ({ type: "SaveFinished", success: true }), + (error) => ({ type: "SaveFinished", success: false, error }), + ), + ]; + } + + case "SaveFinished": { + return [{ ...model, saveStatus: msg.success ? "Saved" : "Error", error: msg.error ?? null }, none]; + } + + case "OpenDocRequested": { + return [ + { ...model, isLoading: true, error: null }, + docOpen( + msg.docRef, + (doc) => ({ type: "DocOpened", doc }), + (error) => ({ type: "DocOpenFinished", success: false, error }), + ), + ]; + } + + case "DocOpened": { + return [{ + ...model, + docRef: { location_id: msg.doc.meta.location_id, rel_path: msg.doc.meta.rel_path }, + text: msg.doc.text, + saveStatus: "Saved", + isLoading: false, + error: null, + }, none]; + } + + case "DocOpenFinished": { + return [{ ...model, isLoading: false, error: msg.error ?? null }, none]; + } + + case "CursorMoved": { + return [{ ...model, cursorLine: msg.line, cursorColumn: msg.column }, none]; + } + + case "SelectionChanged": { + return [{ ...model, selectionFrom: msg.from, selectionTo: msg.to }, none]; + } + + default: + return [model, none]; + } +} + +export interface UseEditorReturn { + model: EditorModel; + dispatch: (msg: EditorMsg) => void; + openDoc: (docRef: DocRef) => void; + saveDoc: () => void; +} + +/** + * Hook for managing editor state with Elm-style architecture. + */ +export function useEditor(): UseEditorReturn { + const [model, setModel] = useState(initialEditorModel); + + const dispatch = useCallback((msg: EditorMsg) => { + setModel((prevModel) => { + const [newModel, cmd] = updateEditor(prevModel, msg); + if (cmd.type !== "None") { + runCmd(cmd); + } + return newModel; + }); + }, []); + + const openDoc = useCallback((docRef: DocRef) => { + dispatch({ type: "OpenDocRequested", docRef }); + }, [dispatch]); + + const saveDoc = useCallback(() => { + dispatch({ type: "SaveRequested" }); + }, [dispatch]); + + return { model, dispatch, openDoc, saveDoc }; +} diff --git a/src/ports.ts b/src/ports.ts index 6dfe54b..d09efe7 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -216,3 +216,51 @@ export function locationValidate( ): Cmd { return invokeCmd>("location_validate", {}, onOk, onErr); } + +export type DocRef = { location_id: LocationId; rel_path: string }; + +export type DocMeta = { + location_id: LocationId; + rel_path: string; + title: string; + updated_at: string; + word_count: number; +}; + +export type DocContent = { text: string; meta: DocMeta }; + +export type SaveStatus = "Idle" | "Dirty" | "Saving" | "Saved" | "Error"; + +export type EditorState = { + doc_ref: DocRef | null; + text: string; + save_status: SaveStatus; + cursor_line: number; + cursor_column: number; + selection_from: number | null; + selection_to: number | null; +}; + +export type EditorMsg = + | { type: "EditorChanged"; text: string } + | { type: "SaveRequested" } + | { type: "SaveFinished"; success: boolean; error?: AppError } + | { type: "DocOpened"; doc: DocContent } + | { type: "CursorMoved"; line: number; column: number } + | { type: "SelectionChanged"; from: number; to: number | null }; + +export function docList( + locationId: LocationId, + onOk: (docs: DocMeta[]) => void, + onErr: (error: AppError) => void, +): Cmd { + return invokeCmd("doc_list", { locationId }, onOk, onErr); +} + +export function docOpen(docRef: DocRef, onOk: (doc: DocContent) => void, onErr: (error: AppError) => void): Cmd { + return invokeCmd("doc_open", { docRef }, onOk, onErr); +} + +export function docSave(docRef: DocRef, text: string, onOk: () => void, onErr: (error: AppError) => void): Cmd { + return invokeCmd("doc_save", { docRef, text }, onOk, onErr); +} diff --git a/src/test/setup.ts b/src/test/setup.ts index f5f1d0d..349931d 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,7 +1,3 @@ -/** - * Vitest test setup file - */ - import "@testing-library/jest-dom"; import { vi } from "vitest"; @@ -45,3 +41,129 @@ console.warn = (...args: unknown[]) => { } originalWarn.apply(console, args); }; + +Object.defineProperty(globalThis, "DOMRect", { + writable: true, + value: class DOMRect { + x = 0; + y = 0; + width = 0; + height = 0; + top = 0; + right = 0; + bottom = 0; + left = 0; + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.top = y; + this.right = x + width; + this.bottom = y + height; + this.left = x; + } + static fromRect(rect: { x?: number; y?: number; width?: number; height?: number }) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left, + }; + } + }, +}); + +if (!globalThis.Range.prototype.getClientRects) { + Object.defineProperty(globalThis.Range.prototype, "getClientRects", { + value: () => ({ length: 0, item: () => null, [Symbol.iterator]: function*() {} }), + }); +} + +if (!globalThis.Range.prototype.getBoundingClientRect) { + Object.defineProperty(globalThis.Range.prototype, "getBoundingClientRect", { value: () => new DOMRect() }); +} + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(globalThis, "ResizeObserver", { writable: true, value: ResizeObserverMock }); + +Object.defineProperty(globalThis, "Selection", { + writable: true, + value: class Selection { + anchorNode: Node | null = null; + anchorOffset = 0; + focusNode: Node | null = null; + focusOffset = 0; + isCollapsed = true; + rangeCount = 0; + type = "None"; + + addRange() {} + collapse() {} + collapseToEnd() {} + collapseToStart() {} + containsNode() { + return false; + } + deleteFromDocument() {} + empty() {} + extend() {} + getRangeAt() { + return null; + } + removeAllRanges() {} + removeRange() {} + selectAllChildren() {} + setBaseAndExtent() {} + toString() { + return ""; + } + }, +}); + +globalThis.getSelection = () => new Selection(); + +Element.prototype.getBoundingClientRect = function() { + return new DOMRect(0, 0, 100, 20); +}; + +globalThis.document.elementFromPoint = () => null; + +Element.prototype.scrollTo = function() {}; +Element.prototype.scrollBy = function() {}; + +class IntersectionObserverMock { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} +Object.defineProperty(globalThis, "IntersectionObserver", { writable: true, value: IntersectionObserverMock }); + +Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/src/themes/oxocarbon-dark.ts b/src/themes/oxocarbon-dark.ts new file mode 100644 index 0000000..4ffff44 --- /dev/null +++ b/src/themes/oxocarbon-dark.ts @@ -0,0 +1,190 @@ +/** + * Oxocarbon Dark Theme for CodeMirror 6 + * + * Based on IBM's Carbon Design System color palette. + * A deep, accessible dark theme with vibrant accent colors. + * + * @see https://carbondesignsystem.com/guidelines/color/overview/ + */ + +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags } from "@lezer/highlight"; + +const colors = { + background: "#161616", + surface: "#262626", + text: "#f4f4f4", + textMuted: "#a8a8a8", + textDisabled: "#6f6f6f", + border: "#393939", + blue: "#78a9ff", + cyan: "#33b1ff", + green: "#42be65", + magenta: "#ff7eb6", + orange: "#ff832b", + purple: "#be95ff", + red: "#fa4d56", + teal: "#08bdba", + yellow: "#f1c21b", + white: "#ffffff", + black: "#000000", +}; + +export const oxocarbonDarkTheme = EditorView.theme({ + "&": { + backgroundColor: colors.background, + color: colors.text, + fontSize: "14px", + fontFamily: "\"IBM Plex Mono\", \"SF Mono\", Monaco, monospace", + }, + ".cm-content": { caretColor: colors.cyan, padding: "16px 0" }, + ".cm-cursor": { borderLeftColor: colors.cyan, borderLeftWidth: "2px" }, + "&.cm-focused .cm-cursor": { borderLeftColor: colors.cyan }, + "&.cm-focused .cm-selectionBackground": { backgroundColor: "rgba(120, 169, 255, 0.3)" }, + ".cm-selectionBackground": { backgroundColor: "rgba(120, 169, 255, 0.2)" }, + ".cm-activeLine": { backgroundColor: "rgba(57, 57, 57, 0.5)" }, + ".cm-activeLineGutter": { backgroundColor: colors.surface }, + ".cm-gutters": { + backgroundColor: colors.background, + color: colors.textMuted, + borderRight: `1px solid ${colors.border}`, + paddingRight: "8px", + }, + ".cm-lineNumbers .cm-gutterElement": { padding: "0 8px", minWidth: "32px" }, + ".cm-foldPlaceholder": { + backgroundColor: colors.surface, + border: `1px solid ${colors.border}`, + borderRadius: "3px", + color: colors.textMuted, + padding: "0 4px", + }, + ".cm-foldGutter .cm-gutterElement": { color: colors.textMuted, cursor: "pointer" }, + ".cm-tooltip": { + backgroundColor: colors.surface, + border: `1px solid ${colors.border}`, + borderRadius: "4px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.5)", + }, + ".cm-tooltip-autocomplete": { backgroundColor: colors.surface }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: colors.blue, color: colors.white }, + ".cm-completionLabel": { color: colors.text }, + ".cm-completionDetail": { color: colors.textMuted }, + ".cm-completionIcon": { color: colors.cyan }, + ".cm-searchMatch": { backgroundColor: "rgba(255, 193, 27, 0.3)", outline: `1px solid ${colors.yellow}` }, + ".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "rgba(255, 193, 27, 0.5)" }, + ".cm-panel": { backgroundColor: colors.surface, borderTop: `1px solid ${colors.border}` }, + ".cm-panel button": { + backgroundColor: colors.blue, + color: colors.white, + border: "none", + borderRadius: "3px", + padding: "4px 12px", + cursor: "pointer", + }, + ".cm-panel input": { + backgroundColor: colors.background, + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: "3px", + padding: "4px 8px", + }, + ".cm-placeholder": { color: colors.textMuted }, + ".cm-highlight": { backgroundColor: "rgba(255, 193, 27, 0.3)" }, + ".cm-specialChar": { color: colors.red }, + "& .cm-scroller::-webkit-scrollbar": { width: "10px", height: "10px" }, + "& .cm-scroller::-webkit-scrollbar-track": { background: colors.background }, + "& .cm-scroller::-webkit-scrollbar-thumb": { background: colors.border, borderRadius: "5px" }, + "& .cm-scroller::-webkit-scrollbar-thumb:hover": { background: colors.textDisabled }, + "& .cm-scroller": { scrollbarColor: `${colors.border} ${colors.background}`, scrollbarWidth: "thin" }, +}, { dark: true }); + +export const oxocarbonDarkHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: colors.magenta, fontWeight: "bold" }, + { tag: tags.controlKeyword, color: colors.magenta }, + { tag: tags.definitionKeyword, color: colors.magenta }, + { tag: tags.modifier, color: colors.magenta }, + { tag: tags.operatorKeyword, color: colors.magenta }, + { tag: tags.function(tags.variableName), color: colors.blue }, + { tag: tags.function(tags.definition(tags.variableName)), color: colors.blue }, + { tag: tags.labelName, color: colors.cyan }, + { tag: tags.namespace, color: colors.cyan }, + { tag: tags.macroName, color: colors.cyan }, + { tag: tags.literal, color: colors.teal }, + { tag: tags.string, color: colors.green }, + { tag: tags.docString, color: colors.green }, + { tag: tags.character, color: colors.green }, + { tag: tags.attributeValue, color: colors.green }, + { tag: tags.number, color: colors.teal }, + { tag: tags.integer, color: colors.teal }, + { tag: tags.float, color: colors.teal }, + { tag: tags.bool, color: colors.teal }, + { tag: tags.regexp, color: colors.orange }, + { tag: tags.escape, color: colors.orange }, + { tag: tags.color, color: colors.orange }, + { tag: tags.url, color: colors.cyan, textDecoration: "underline" }, + { tag: tags.keyword, color: colors.magenta }, + { tag: tags.self, color: colors.purple }, + { tag: tags.null, color: colors.purple }, + { tag: tags.atom, color: colors.purple }, + { tag: tags.unit, color: colors.purple }, + { tag: tags.modifier, color: colors.magenta }, + { tag: tags.operator, color: colors.cyan }, + { tag: tags.derefOperator, color: colors.cyan }, + { tag: tags.arithmeticOperator, color: colors.cyan }, + { tag: tags.logicOperator, color: colors.cyan }, + { tag: tags.bitwiseOperator, color: colors.cyan }, + { tag: tags.compareOperator, color: colors.cyan }, + { tag: tags.updateOperator, color: colors.cyan }, + { tag: tags.definitionOperator, color: colors.cyan }, + { tag: tags.typeOperator, color: colors.magenta }, + { tag: tags.controlOperator, color: colors.cyan }, + { tag: tags.punctuation, color: colors.text }, + { tag: tags.separator, color: colors.text }, + { tag: tags.bracket, color: colors.text }, + { tag: tags.angleBracket, color: colors.text }, + { tag: tags.squareBracket, color: colors.text }, + { tag: tags.paren, color: colors.text }, + { tag: tags.brace, color: colors.text }, + { tag: tags.contentSeparator, color: colors.text }, + { tag: tags.comment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.lineComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.blockComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.docComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.name, color: colors.text }, + { tag: tags.variableName, color: colors.text }, + { tag: tags.typeName, color: colors.yellow }, + { tag: tags.tagName, color: colors.blue }, + { tag: tags.attributeName, color: colors.cyan }, + { tag: tags.className, color: colors.yellow }, + { tag: tags.labelName, color: colors.cyan }, + { tag: tags.namespace, color: colors.cyan }, + { tag: tags.macroName, color: colors.cyan }, + { tag: tags.propertyName, color: colors.cyan }, + { tag: tags.special(tags.propertyName), color: colors.cyan }, + { tag: tags.heading1, color: colors.blue, fontWeight: "bold", fontSize: "1.5em" }, + { tag: tags.heading2, color: colors.blue, fontWeight: "bold", fontSize: "1.3em" }, + { tag: tags.heading3, color: colors.blue, fontWeight: "bold", fontSize: "1.1em" }, + { tag: tags.heading4, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading5, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading6, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading, color: colors.blue, fontWeight: "bold" }, + { tag: tags.quote, color: colors.green, fontStyle: "italic" }, + { tag: tags.list, color: colors.cyan }, + { tag: tags.emphasis, color: colors.text, fontStyle: "italic" }, + { tag: tags.strong, color: colors.text, fontWeight: "bold" }, + { tag: tags.strikethrough, color: colors.textMuted, textDecoration: "line-through" }, + { tag: tags.link, color: colors.cyan, textDecoration: "underline" }, + { tag: tags.monospace, color: colors.orange, fontFamily: "monospace" }, + { tag: tags.processingInstruction, color: colors.magenta }, + { tag: tags.annotation, color: colors.yellow }, + { tag: tags.invalid, color: colors.red, borderBottom: `1px dotted ${colors.red}` }, + { tag: tags.meta, color: colors.textMuted }, + { tag: tags.documentMeta, color: colors.textMuted }, + + { tag: tags.changed, color: colors.yellow }, + { tag: tags.deleted, color: colors.red }, + { tag: tags.inserted, color: colors.green }, +]); + +export const oxocarbonDark = [oxocarbonDarkTheme, syntaxHighlighting(oxocarbonDarkHighlight)]; diff --git a/src/themes/oxocarbon-light.ts b/src/themes/oxocarbon-light.ts new file mode 100644 index 0000000..942de1b --- /dev/null +++ b/src/themes/oxocarbon-light.ts @@ -0,0 +1,189 @@ +/** + * Oxocarbon Light Theme for CodeMirror 6 + * + * Based on IBM's Carbon Design System color palette. + * A clean, accessible light theme with vibrant accent colors. + * + * @see https://carbondesignsystem.com/guidelines/color/overview/ + */ + +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags } from "@lezer/highlight"; + +const colors = { + background: "#ffffff", + surface: "#f4f4f4", + text: "#161616", + textMuted: "#525252", + textDisabled: "#8d8d8d", + border: "#e0e0e0", + blue: "#0f62fe", + cyan: "#0072c3", + green: "#198038", + magenta: "#d12771", + orange: "#ba4e00", + purple: "#8a3ffc", + red: "#da1e28", + teal: "#007d79", + yellow: "#b28600", + white: "#ffffff", + black: "#000000", +}; + +export const oxocarbonLightTheme = EditorView.theme({ + "&": { + backgroundColor: colors.background, + color: colors.text, + fontSize: "14px", + fontFamily: "\"IBM Plex Mono\", \"SF Mono\", Monaco, monospace", + }, + ".cm-content": { caretColor: colors.cyan, padding: "16px 0" }, + ".cm-cursor": { borderLeftColor: colors.cyan, borderLeftWidth: "2px" }, + "&.cm-focused .cm-cursor": { borderLeftColor: colors.cyan }, + "&.cm-focused .cm-selectionBackground": { backgroundColor: "rgba(15, 98, 254, 0.2)" }, + ".cm-selectionBackground": { backgroundColor: "rgba(15, 98, 254, 0.15)" }, + ".cm-activeLine": { backgroundColor: "rgba(224, 224, 224, 0.4)" }, + ".cm-activeLineGutter": { backgroundColor: colors.surface }, + ".cm-gutters": { + backgroundColor: colors.background, + color: colors.textMuted, + borderRight: `1px solid ${colors.border}`, + paddingRight: "8px", + }, + ".cm-lineNumbers .cm-gutterElement": { padding: "0 8px", minWidth: "32px" }, + ".cm-foldPlaceholder": { + backgroundColor: colors.surface, + border: `1px solid ${colors.border}`, + borderRadius: "3px", + color: colors.textMuted, + padding: "0 4px", + }, + ".cm-foldGutter .cm-gutterElement": { color: colors.textMuted, cursor: "pointer" }, + ".cm-tooltip": { + backgroundColor: colors.surface, + border: `1px solid ${colors.border}`, + borderRadius: "4px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + ".cm-tooltip-autocomplete": { backgroundColor: colors.surface }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: colors.blue, color: colors.white }, + ".cm-completionLabel": { color: colors.text }, + ".cm-completionDetail": { color: colors.textMuted }, + ".cm-completionIcon": { color: colors.cyan }, + ".cm-searchMatch": { backgroundColor: "rgba(255, 193, 27, 0.4)", outline: `1px solid ${colors.yellow}` }, + ".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "rgba(255, 193, 27, 0.6)" }, + ".cm-panel": { backgroundColor: colors.surface, borderTop: `1px solid ${colors.border}` }, + ".cm-panel button": { + backgroundColor: colors.blue, + color: colors.white, + border: "none", + borderRadius: "3px", + padding: "4px 12px", + cursor: "pointer", + }, + ".cm-panel input": { + backgroundColor: colors.background, + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: "3px", + padding: "4px 8px", + }, + ".cm-placeholder": { color: colors.textDisabled }, + ".cm-highlight": { backgroundColor: "rgba(255, 193, 27, 0.4)" }, + ".cm-specialChar": { color: colors.red }, + "& .cm-scroller::-webkit-scrollbar": { width: "10px", height: "10px" }, + "& .cm-scroller::-webkit-scrollbar-track": { background: colors.background }, + "& .cm-scroller::-webkit-scrollbar-thumb": { background: colors.border, borderRadius: "5px" }, + "& .cm-scroller::-webkit-scrollbar-thumb:hover": { background: colors.textDisabled }, + "& .cm-scroller": { scrollbarColor: `${colors.border} ${colors.background}`, scrollbarWidth: "thin" }, +}, { dark: false }); + +export const oxocarbonLightHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: colors.magenta, fontWeight: "bold" }, + { tag: tags.controlKeyword, color: colors.magenta }, + { tag: tags.definitionKeyword, color: colors.magenta }, + { tag: tags.modifier, color: colors.magenta }, + { tag: tags.operatorKeyword, color: colors.magenta }, + { tag: tags.function(tags.variableName), color: colors.blue }, + { tag: tags.function(tags.definition(tags.variableName)), color: colors.blue }, + { tag: tags.labelName, color: colors.cyan }, + { tag: tags.namespace, color: colors.cyan }, + { tag: tags.macroName, color: colors.cyan }, + { tag: tags.literal, color: colors.teal }, + { tag: tags.string, color: colors.green }, + { tag: tags.docString, color: colors.green }, + { tag: tags.character, color: colors.green }, + { tag: tags.attributeValue, color: colors.green }, + { tag: tags.number, color: colors.teal }, + { tag: tags.integer, color: colors.teal }, + { tag: tags.float, color: colors.teal }, + { tag: tags.bool, color: colors.teal }, + { tag: tags.regexp, color: colors.orange }, + { tag: tags.escape, color: colors.orange }, + { tag: tags.color, color: colors.orange }, + { tag: tags.url, color: colors.cyan, textDecoration: "underline" }, + { tag: tags.keyword, color: colors.magenta }, + { tag: tags.self, color: colors.purple }, + { tag: tags.null, color: colors.purple }, + { tag: tags.atom, color: colors.purple }, + { tag: tags.unit, color: colors.purple }, + { tag: tags.modifier, color: colors.magenta }, + { tag: tags.operator, color: colors.cyan }, + { tag: tags.derefOperator, color: colors.cyan }, + { tag: tags.arithmeticOperator, color: colors.cyan }, + { tag: tags.logicOperator, color: colors.cyan }, + { tag: tags.bitwiseOperator, color: colors.cyan }, + { tag: tags.compareOperator, color: colors.cyan }, + { tag: tags.updateOperator, color: colors.cyan }, + { tag: tags.definitionOperator, color: colors.cyan }, + { tag: tags.typeOperator, color: colors.magenta }, + { tag: tags.controlOperator, color: colors.cyan }, + { tag: tags.punctuation, color: colors.text }, + { tag: tags.separator, color: colors.text }, + { tag: tags.bracket, color: colors.text }, + { tag: tags.angleBracket, color: colors.text }, + { tag: tags.squareBracket, color: colors.text }, + { tag: tags.paren, color: colors.text }, + { tag: tags.brace, color: colors.text }, + { tag: tags.contentSeparator, color: colors.text }, + { tag: tags.comment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.lineComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.blockComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.docComment, color: colors.textMuted, fontStyle: "italic" }, + { tag: tags.name, color: colors.text }, + { tag: tags.variableName, color: colors.text }, + { tag: tags.typeName, color: colors.yellow }, + { tag: tags.tagName, color: colors.blue }, + { tag: tags.attributeName, color: colors.cyan }, + { tag: tags.className, color: colors.yellow }, + { tag: tags.labelName, color: colors.cyan }, + { tag: tags.namespace, color: colors.cyan }, + { tag: tags.macroName, color: colors.cyan }, + { tag: tags.propertyName, color: colors.cyan }, + { tag: tags.special(tags.propertyName), color: colors.cyan }, + { tag: tags.heading1, color: colors.blue, fontWeight: "bold", fontSize: "1.5em" }, + { tag: tags.heading2, color: colors.blue, fontWeight: "bold", fontSize: "1.3em" }, + { tag: tags.heading3, color: colors.blue, fontWeight: "bold", fontSize: "1.1em" }, + { tag: tags.heading4, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading5, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading6, color: colors.blue, fontWeight: "bold" }, + { tag: tags.heading, color: colors.blue, fontWeight: "bold" }, + { tag: tags.quote, color: colors.green, fontStyle: "italic" }, + { tag: tags.list, color: colors.cyan }, + { tag: tags.emphasis, color: colors.text, fontStyle: "italic" }, + { tag: tags.strong, color: colors.text, fontWeight: "bold" }, + { tag: tags.strikethrough, color: colors.textMuted, textDecoration: "line-through" }, + { tag: tags.link, color: colors.cyan, textDecoration: "underline" }, + { tag: tags.monospace, color: colors.orange, fontFamily: "monospace" }, + { tag: tags.processingInstruction, color: colors.magenta }, + { tag: tags.annotation, color: colors.yellow }, + { tag: tags.invalid, color: colors.red, borderBottom: `1px dotted ${colors.red}` }, + { tag: tags.meta, color: colors.textMuted }, + { tag: tags.documentMeta, color: colors.textMuted }, + { tag: tags.changed, color: colors.yellow }, + { tag: tags.deleted, color: colors.red }, + { tag: tags.inserted, color: colors.green }, +]); + +export const oxocarbonLight = [oxocarbonLightTheme, syntaxHighlighting(oxocarbonLightHighlight)]; diff --git a/src/usePorts.ts b/src/usePorts.ts index d5abb32..f67c831 100644 --- a/src/usePorts.ts +++ b/src/usePorts.ts @@ -23,6 +23,8 @@ import { SubscriptionManager, } from "./ports"; +export type { AppError, LocationDescriptor } from "./ports"; + interface UsePortsState { data: T | null; error: AppError | null; diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..64cb712 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -13,7 +12,6 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, -- 2.51.2