pretext-editor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pretext contributors
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.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # pretext-editor
2
+
3
+ High-performance Canvas-virtualized code editor for React. Renders only visible
4
+ lines via Canvas 2D, so it stays smooth even on very large files.
5
+
6
+ ## How it works
7
+
8
+ | Layer | Technology |
9
+ |---|---|
10
+ | Rendering | Canvas 2D API — only visible lines are drawn each frame |
11
+ | Text layout | [@chenglou/pretext](https://github.com/chenglou/pretext) |
12
+ | Syntax highlighting | [Shiki](https://shiki.style/) with JavaScript regex engine (no WASM) |
13
+ | Tokenization cache | [@tanstack/react-query](https://tanstack.com/query) — results cached, `useDeferredValue` keeps typing fast |
14
+ | Keyboard / IME input | Hidden `<textarea>` captures all keyboard events and IME composition |
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @molang/pretext-editor
20
+ ```
21
+
22
+ Wrap your app with `QueryClientProvider` once (skip if you already have it):
23
+
24
+ ```tsx
25
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
26
+
27
+ const queryClient = new QueryClient()
28
+
29
+ function App() {
30
+ return (
31
+ <QueryClientProvider client={queryClient}>
32
+ <YourApp />
33
+ </QueryClientProvider>
34
+ )
35
+ }
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```tsx
41
+ import { useState } from 'react'
42
+ import { PretextEditor } from '@molang/pretext-editor'
43
+
44
+ export function Editor() {
45
+ const [code, setCode] = useState('// hello world\n')
46
+
47
+ return (
48
+ <div style={{ height: 600 }}>
49
+ <PretextEditor
50
+ value={code}
51
+ onChange={setCode}
52
+ language="typescript"
53
+ fontSize={14}
54
+ fontFamily='Menlo, Monaco, "Courier New", monospace'
55
+ tabSize={4}
56
+ />
57
+ </div>
58
+ )
59
+ }
60
+ ```
61
+
62
+ The component fills its parent — give the parent an explicit height.
63
+
64
+ ## Props
65
+
66
+ | Prop | Type | Default | Description |
67
+ |---|---|---|---|
68
+ | `value` | `string` | — | Document content (controlled) |
69
+ | `onChange` | `(value: string) => void` | — | Called on every edit |
70
+ | `language` | `string` | `undefined` | Shiki language id (`"typescript"`, `"go"`, `"python"` …). Omit to disable highlighting. |
71
+ | `fontSize` | `number` | `14` | Font size in px |
72
+ | `fontFamily` | `string` | `"monospace"` | CSS font-family string |
73
+ | `tabSize` | `number` | `4` | Visual width of `\t` in spaces. Raw `\t` is preserved in content. |
74
+ | `className` | `string` | — | Class on the scroll container |
75
+ | `style` | `CSSProperties` | — | Inline style on the scroll container |
76
+
77
+ ## Supported languages
78
+
79
+ Any language bundled with Shiki is supported. Common ones:
80
+
81
+ `typescript` · `tsx` · `javascript` · `jsx` · `python` · `rust` · `go` ·
82
+ `c` · `cpp` · `csharp` · `java` · `kotlin` · `swift` · `ruby` · `php` ·
83
+ `css` · `scss` · `html` · `vue` · `svelte` · `json` · `yaml` · `toml` ·
84
+ `markdown` · `bash` · `sql` · `graphql`
85
+
86
+ Pass the extension → language id mapping helper if needed:
87
+
88
+ ```tsx
89
+ import { extToLang } from '@molang/pretext-editor'
90
+
91
+ const lang = extToLang('ts') // → "typescript"
92
+ ```
93
+
94
+ ## Keyboard shortcuts
95
+
96
+ | Key | Action |
97
+ |---|---|
98
+ | Arrow keys | Move cursor |
99
+ | Shift + Arrow | Extend selection |
100
+ | Home / End | Jump to line start / end |
101
+ | Ctrl/⌘ + ← / → | Jump to line start / end |
102
+ | Ctrl/⌘ + A | Select all |
103
+ | Ctrl/⌘ + C / X / V | Copy / Cut / Paste |
104
+ | Ctrl/⌘ + Z | Undo |
105
+ | Ctrl/⌘ + Shift+Z / Y | Redo |
106
+ | Tab | Insert two spaces |
107
+ | Backspace / Delete | Delete backward / forward |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pretext-editor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "High-performance Canvas-virtualized text editor for large documents",
6
6
  "type": "module",
@@ -10,11 +10,13 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
+ "@tanstack/react-query": ">=5.0.0",
13
14
  "react": ">=19.0.0",
14
15
  "react-dom": ">=19.0.0"
15
16
  },
16
17
  "dependencies": {
17
- "@chenglou/pretext": "^0.0.8"
18
+ "@chenglou/pretext": "^0.0.8",
19
+ "shiki": "^4.2.0"
18
20
  },
19
21
  "devDependencies": {
20
22
  "@types/react": "^19.2.7",
@@ -1,8 +1,12 @@
1
1
  import type { Cursor, Selection } from './document'
2
2
  import { isCollapsed, normalizeSelection } from './document'
3
3
 
4
+ export type TokenSpan = { text: string; color: string }
5
+ export type TokenizedLine = TokenSpan[]
6
+
4
7
  export const DEFAULT_FONT_SIZE = 14
5
8
  export const DEFAULT_FONT_FAMILY = 'monospace'
9
+ export const DEFAULT_TAB_SIZE = 4
6
10
  export const PADDING_LEFT = 52
7
11
  export const PADDING_TOP = 8
8
12
  export const FONT_SIZE_TO_LINE_HEIGHT = (size: number): number => size + 8
@@ -11,10 +15,11 @@ const BG = '#1e1e1e'
11
15
  const FG = '#d4d4d4'
12
16
  const GUTTER_BG = '#1e1e1e'
13
17
  const GUTTER_FG = '#6e7681'
14
- const GUTTER_BORDER = '#2b2b2b'
15
18
  const CURSOR_COLOR = '#d4d4d4'
16
19
  const CURRENT_LINE_BG = 'rgba(255,255,255,0.04)'
17
20
  const SELECTION_BG = '#264f78'
21
+ const INDENT_GUIDE = 'rgba(255,255,255,0.1)'
22
+ const INDENT_GUIDE_ACTIVE = 'rgba(255,255,255,0.3)'
18
23
 
19
24
  export interface RenderOptions {
20
25
  canvas: HTMLCanvasElement
@@ -24,6 +29,124 @@ export interface RenderOptions {
24
29
  scrollTop: number
25
30
  fontSize?: number
26
31
  fontFamily?: string
32
+ tabSize?: number
33
+ tokenLines?: TokenizedLine[]
34
+ }
35
+
36
+ /** Count leading-whitespace depth in spaces (tabs expand to tabSize) */
37
+ function indentDepth(text: string, tabSize: number): number {
38
+ let depth = 0
39
+ for (const ch of text) {
40
+ if (ch === ' ') depth++
41
+ else if (ch === '\t') depth = Math.ceil((depth + 1) / tabSize) * tabSize
42
+ else break
43
+ }
44
+ return depth
45
+ }
46
+
47
+ /** Detect the file's actual indent unit (minimum non-zero indent depth). */
48
+ function detectIndentUnit(lines: string[], tabSize: number): number {
49
+ let unit = Infinity
50
+ for (const line of lines) {
51
+ if (line.trim() === '') continue
52
+ const d = indentDepth(line, tabSize)
53
+ if (d > 0) unit = Math.min(unit, d)
54
+ }
55
+ return isFinite(unit) ? unit : tabSize
56
+ }
57
+
58
+ function buildGuideData(
59
+ lines: string[],
60
+ tabSize: number,
61
+ ): { rawLevels: number[]; effectiveLevels: number[]; indentUnit: number } {
62
+ const indentUnit = detectIndentUnit(lines, tabSize)
63
+ const rawLevels = lines.map((l) =>
64
+ l.trim() === '' ? -1 : Math.floor(indentDepth(l, tabSize) / indentUnit),
65
+ )
66
+ const effectiveLevels = rawLevels.slice()
67
+ let prev = 0
68
+ for (let i = 0; i < effectiveLevels.length; i++) {
69
+ if (effectiveLevels[i] === -1) effectiveLevels[i] = prev
70
+ else prev = effectiveLevels[i]
71
+ }
72
+ let next = 0
73
+ for (let i = effectiveLevels.length - 1; i >= 0; i--) {
74
+ if (rawLevels[i] === -1) effectiveLevels[i] = Math.min(effectiveLevels[i], next)
75
+ else next = effectiveLevels[i]
76
+ }
77
+ return { rawLevels, effectiveLevels, indentUnit }
78
+ }
79
+
80
+ /**
81
+ * Walk upward from cursorLine, bracket-balance scanning, to find the innermost
82
+ * enclosing bracket pair ({}/[]/()). Returns the indent level of the content
83
+ * inside that pair. Falls back to the cursor line's own level when no enclosing
84
+ * bracket exists (mirrors Monaco's indentation-based fallback).
85
+ */
86
+ function activeBracketLevel(
87
+ lines: string[],
88
+ cursorLine: number,
89
+ rawLevels: number[],
90
+ effectiveLevels: number[],
91
+ ): number {
92
+ const lvl = (i: number) => (rawLevels[i] === -1 ? (effectiveLevels[i] ?? 0) : rawLevels[i])
93
+ let depth = 0
94
+ for (let i = cursorLine; i >= 0; i--) {
95
+ const text = lines[i]
96
+ for (let j = text.length - 1; j >= 0; j--) {
97
+ const ch = text[j]
98
+ if (ch === '}' || ch === ']' || ch === ')') {
99
+ depth++
100
+ } else if (ch === '{' || ch === '[' || ch === '(') {
101
+ if (depth > 0) {
102
+ depth--
103
+ } else {
104
+ // innermost enclosing opener at line i — content level is the first non-empty line after it
105
+ for (let k = i + 1; k < lines.length && k <= i + 100; k++) {
106
+ if (lines[k].trim() !== '') return lvl(k)
107
+ }
108
+ return lvl(i) + 1
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return lvl(cursorLine)
114
+ }
115
+
116
+ /** Measure text width, treating \t as tabSize spaces */
117
+ function measureWithTabs(ctx: CanvasRenderingContext2D, text: string, tabWidth: number): number {
118
+ if (!text.includes('\t')) return ctx.measureText(text).width
119
+ const spaceW = ctx.measureText(' ').width
120
+ let w = 0
121
+ for (const ch of text) {
122
+ w += ch === '\t' ? spaceW * tabWidth : ctx.measureText(ch).width
123
+ }
124
+ return w
125
+ }
126
+
127
+ /** Draw text, treating \t as tabSize spaces */
128
+ function fillTextWithTabs(
129
+ ctx: CanvasRenderingContext2D,
130
+ text: string,
131
+ x: number,
132
+ y: number,
133
+ tabWidth: number,
134
+ ): number {
135
+ if (!text.includes('\t')) {
136
+ ctx.fillText(text, x, y)
137
+ return x + ctx.measureText(text).width
138
+ }
139
+ const spaceW = ctx.measureText(' ').width
140
+ let xOff = x
141
+ for (const ch of text) {
142
+ if (ch === '\t') {
143
+ xOff += spaceW * tabWidth
144
+ } else {
145
+ ctx.fillText(ch, xOff, y)
146
+ xOff += ctx.measureText(ch).width
147
+ }
148
+ }
149
+ return xOff
27
150
  }
28
151
 
29
152
  export function renderCanvas({
@@ -34,6 +157,8 @@ export function renderCanvas({
34
157
  scrollTop,
35
158
  fontSize = DEFAULT_FONT_SIZE,
36
159
  fontFamily = DEFAULT_FONT_FAMILY,
160
+ tabSize = DEFAULT_TAB_SIZE,
161
+ tokenLines,
37
162
  }: RenderOptions): void {
38
163
  const ctx = canvas.getContext('2d')
39
164
  if (!ctx) return
@@ -55,6 +180,10 @@ export function renderCanvas({
55
180
 
56
181
  ctx.font = font
57
182
 
183
+ const spaceW = ctx.measureText(' ').width
184
+ const { rawLevels, effectiveLevels, indentUnit } = buildGuideData(lines, tabSize)
185
+ const activeGuideLevel = activeBracketLevel(lines, cursor.line, rawLevels, effectiveLevels)
186
+
58
187
  const hasSel = selection && !isCollapsed(selection)
59
188
  const [selStart, selEnd] = hasSel ? normalizeSelection(selection!) : [cursor, cursor]
60
189
 
@@ -68,16 +197,28 @@ export function renderCanvas({
68
197
  ctx.fillRect(0, y, w, lineHeight)
69
198
  }
70
199
 
71
- // Selection highlight (drawn before text so text is readable on top)
200
+ // Indent guides draw at every level strictly contained by this line's indent.
201
+ // Rule: guide g appears if rawLevel >= g (non-empty) or effectiveLevel >= g (empty).
202
+ // This means {-lines and }-lines at level g-1 are excluded naturally.
203
+ const rl = rawLevels[i]
204
+ const el = effectiveLevels[i] ?? 0
205
+ const maxG = rl === -1 ? el : rl
206
+ for (let g = 1; g <= maxG; g++) {
207
+ const gx = Math.floor(PADDING_LEFT + 4 + (g - 1) * indentUnit * spaceW)
208
+ ctx.fillStyle = g === activeGuideLevel ? INDENT_GUIDE_ACTIVE : INDENT_GUIDE
209
+ ctx.fillRect(gx, y, 1, lineHeight)
210
+ }
211
+
212
+ // Selection highlight
72
213
  if (hasSel && i >= selStart.line && i <= selEnd.line) {
73
214
  const colStart = i === selStart.line ? selStart.col : 0
74
215
  const colEnd = i === selEnd.line ? selEnd.col : lineText.length
75
216
 
76
217
  ctx.font = font
77
- const xStart = PADDING_LEFT + 4 + ctx.measureText(lineText.slice(0, colStart)).width
218
+ const xStart = PADDING_LEFT + 4 + measureWithTabs(ctx, lineText.slice(0, colStart), tabSize)
78
219
  const xEnd =
79
220
  i === selEnd.line
80
- ? PADDING_LEFT + 4 + ctx.measureText(lineText.slice(0, colEnd)).width
221
+ ? PADDING_LEFT + 4 + measureWithTabs(ctx, lineText.slice(0, colEnd), tabSize)
81
222
  : w
82
223
 
83
224
  ctx.fillStyle = SELECTION_BG
@@ -87,24 +228,32 @@ export function renderCanvas({
87
228
  // Gutter
88
229
  ctx.fillStyle = GUTTER_BG
89
230
  ctx.fillRect(0, y, PADDING_LEFT, lineHeight)
90
- ctx.fillStyle = GUTTER_BORDER
91
- ctx.fillRect(PADDING_LEFT - 1, y, 1, lineHeight)
92
231
 
93
232
  // Line number
94
233
  ctx.fillStyle = i === cursor.line ? FG : GUTTER_FG
95
234
  ctx.textAlign = 'right'
96
235
  ctx.textBaseline = 'top'
97
- ctx.fillText(String(i + 1), PADDING_LEFT - 8, y + Math.floor((lineHeight - fontSize) / 2))
236
+ ctx.fillText(String(i + 1), PADDING_LEFT + 4 - 2 * spaceW, y + Math.floor((lineHeight - fontSize) / 2))
98
237
 
99
- // Line text
100
- ctx.fillStyle = FG
238
+ // Line text (syntax-highlighted or plain)
239
+ const tokenLine = tokenLines?.[i]
240
+ const textY = y + Math.floor((lineHeight - fontSize) / 2)
101
241
  ctx.textAlign = 'left'
102
- ctx.fillText(lineText, PADDING_LEFT + 4, y + Math.floor((lineHeight - fontSize) / 2))
242
+ if (tokenLine && tokenLine.length > 0) {
243
+ let xOff = PADDING_LEFT + 4
244
+ for (const span of tokenLine) {
245
+ ctx.fillStyle = span.color
246
+ xOff = fillTextWithTabs(ctx, span.text, xOff, textY, tabSize)
247
+ }
248
+ } else {
249
+ ctx.fillStyle = FG
250
+ fillTextWithTabs(ctx, lineText, PADDING_LEFT + 4, textY, tabSize)
251
+ }
103
252
 
104
- // Cursor (only when no active selection or selection is collapsed)
253
+ // Cursor
105
254
  if (i === cursor.line) {
106
255
  const textBefore = lineText.slice(0, cursor.col)
107
- const cursorX = PADDING_LEFT + 4 + ctx.measureText(textBefore).width
256
+ const cursorX = PADDING_LEFT + 4 + measureWithTabs(ctx, textBefore, tabSize)
108
257
  ctx.fillStyle = CURSOR_COLOR
109
258
  ctx.fillRect(Math.floor(cursorX), y + 2, 2, lineHeight - 4)
110
259
  }
@@ -113,20 +262,21 @@ export function renderCanvas({
113
262
  ctx.restore()
114
263
  }
115
264
 
116
- /** Find the column index in `line` closest to pixel offset `targetX` */
265
+ /** Find the column index in `line` closest to pixel offset `targetX`, tab-aware */
117
266
  export function colFromX(
118
267
  ctx: CanvasRenderingContext2D,
119
268
  line: string,
120
269
  targetX: number,
121
270
  fontSize: number,
122
271
  fontFamily: string,
272
+ tabSize: number = DEFAULT_TAB_SIZE,
123
273
  ): number {
124
274
  ctx.font = `${fontSize}px ${fontFamily}`
125
275
  let lo = 0
126
276
  let hi = line.length
127
277
  while (lo < hi) {
128
278
  const mid = (lo + hi + 1) >> 1
129
- if (ctx.measureText(line.slice(0, mid)).width <= targetX) {
279
+ if (measureWithTabs(ctx, line.slice(0, mid), tabSize) <= targetX) {
130
280
  lo = mid
131
281
  } else {
132
282
  hi = mid - 1
@@ -0,0 +1,62 @@
1
+ import { createHighlighter, type Highlighter } from 'shiki'
2
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
3
+ import type { BundledLanguage } from 'shiki'
4
+ import type { TokenizedLine } from './renderer'
5
+
6
+ const THEME = 'dark-plus'
7
+ const DEFAULT_FG = '#d4d4d4'
8
+
9
+ let _hlPromise: Promise<Highlighter> | null = null
10
+ const loadedLangs = new Set<string>()
11
+
12
+ const jsEngine = createJavaScriptRegexEngine()
13
+
14
+ function getHighlighter(): Promise<Highlighter> {
15
+ if (!_hlPromise) {
16
+ _hlPromise = createHighlighter({
17
+ themes: [THEME],
18
+ langs: [],
19
+ engine: jsEngine,
20
+ // engine: createOnigurumaEngine(import('shiki/wasm')),
21
+ })
22
+ }
23
+ return _hlPromise
24
+ }
25
+
26
+ export async function tokenize(code: string, lang: string): Promise<TokenizedLine[]> {
27
+ const hl = await getHighlighter()
28
+ if (!loadedLangs.has(lang)) {
29
+ try {
30
+ await hl.loadLanguage(lang as unknown as BundledLanguage)
31
+ loadedLangs.add(lang)
32
+ } catch {
33
+ return []
34
+ }
35
+ }
36
+ try {
37
+ const result = hl.codeToTokens(code, { lang: lang as unknown as BundledLanguage, theme: THEME })
38
+ return result.tokens.map((line) =>
39
+ line.map((t) => ({ text: t.content, color: t.color ?? DEFAULT_FG })),
40
+ )
41
+ } catch {
42
+ return []
43
+ }
44
+ }
45
+
46
+ /** Map file extension → Shiki language id */
47
+ export function extToLang(ext: string): string | undefined {
48
+ const map: Record<string, string> = {
49
+ ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
50
+ py: 'python', rs: 'rust', go: 'go',
51
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', cs: 'csharp',
52
+ java: 'java', kt: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php',
53
+ lua: 'lua', r: 'r', dart: 'dart', scala: 'scala',
54
+ css: 'css', scss: 'scss', less: 'less',
55
+ html: 'html', htm: 'html', xml: 'xml', vue: 'vue', svelte: 'svelte',
56
+ json: 'json', jsonc: 'jsonc', yaml: 'yaml', yml: 'yaml', toml: 'toml',
57
+ md: 'markdown', mdx: 'mdx',
58
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'fish',
59
+ sql: 'sql', graphql: 'graphql',
60
+ }
61
+ return map[ext.toLowerCase()]
62
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export { PretextEditor } from './react/PretextEditor'
2
2
  export type { PretextEditorProps } from './react/PretextEditor'
3
3
  export { fromString, toString, insert, deleteBackward, deleteForward, moveCursor, getSelectedText, deleteSelectedText, isCollapsed, normalizeSelection } from './core/document'
4
4
  export type { Doc, Cursor, Selection } from './core/document'
5
+ export { extToLang } from './core/tokenizer'
6
+ export type { TokenSpan, TokenizedLine } from './core/renderer'
@@ -1,4 +1,7 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, useDeferredValue } from 'react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { tokenize } from '../core/tokenizer'
4
+ import type { TokenizedLine } from '../core/renderer'
2
5
  import {
3
6
  fromString,
4
7
  toString,
@@ -23,14 +26,17 @@ import {
23
26
  FONT_SIZE_TO_LINE_HEIGHT,
24
27
  DEFAULT_FONT_SIZE,
25
28
  DEFAULT_FONT_FAMILY,
29
+ DEFAULT_TAB_SIZE,
26
30
  colFromX,
27
31
  } from '../core/renderer'
28
32
 
29
33
  export interface PretextEditorProps {
30
34
  value: string
31
35
  onChange: (value: string) => void
36
+ language?: string
32
37
  fontSize?: number
33
38
  fontFamily?: string
39
+ tabSize?: number
34
40
  className?: string
35
41
  style?: React.CSSProperties
36
42
  }
@@ -40,8 +46,10 @@ type Snapshot = { doc: Doc; selAnchor: Cursor | null }
40
46
  export function PretextEditor({
41
47
  value,
42
48
  onChange,
49
+ language,
43
50
  fontSize = DEFAULT_FONT_SIZE,
44
51
  fontFamily = DEFAULT_FONT_FAMILY,
52
+ tabSize = DEFAULT_TAB_SIZE,
45
53
  className,
46
54
  style,
47
55
  }: PretextEditorProps): React.JSX.Element {
@@ -75,6 +83,19 @@ export function PretextEditor({
75
83
  setSelAnchor(null)
76
84
  }
77
85
 
86
+ // Syntax highlighting: tokenize with a deferred value so rapid typing doesn't queue many requests
87
+ const deferredValue = useDeferredValue(value)
88
+ const { data: tokenLines } = useQuery<TokenizedLine[]>({
89
+ queryKey: ['tokens', language, deferredValue],
90
+ queryFn: () => tokenize(deferredValue, language!),
91
+ enabled: !!language,
92
+ staleTime: Infinity,
93
+ gcTime: 60_000,
94
+ placeholderData: (prev) => prev,
95
+ })
96
+ const tokenLinesRef = useRef<TokenizedLine[] | undefined>(undefined)
97
+ tokenLinesRef.current = tokenLines
98
+
78
99
  const lineHeight = FONT_SIZE_TO_LINE_HEIGHT(fontSize)
79
100
  const totalHeight = Math.max(1, doc.lines.length) * lineHeight + PADDING_TOP * 2
80
101
 
@@ -126,13 +147,15 @@ export function PretextEditor({
126
147
  scrollTop: container.scrollTop,
127
148
  fontSize,
128
149
  fontFamily,
150
+ tabSize,
151
+ tokenLines: tokenLinesRef.current,
129
152
  })
130
- }, [fontSize, fontFamily])
153
+ }, [fontSize, fontFamily, tabSize])
131
154
 
132
- // Repaint after every state change
155
+ // Repaint after every state change or when syntax tokens arrive
133
156
  useLayoutEffect(() => {
134
157
  repaint()
135
- }, [repaint, doc, selAnchor])
158
+ }, [repaint, doc, selAnchor, tokenLines])
136
159
 
137
160
  // REVIEW: useEffect used because DOM focus must run after mount
138
161
  useEffect(() => {
@@ -191,10 +214,10 @@ export function PretextEditor({
191
214
  )
192
215
  const textX = cssX - (PADDING_LEFT + 4)
193
216
  const col =
194
- textX <= 0 ? 0 : colFromX(ctx, docRef.current.lines[line] ?? '', textX, fontSize, fontFamily)
217
+ textX <= 0 ? 0 : colFromX(ctx, docRef.current.lines[line] ?? '', textX, fontSize, fontFamily, tabSize)
195
218
  return { line, col }
196
219
  },
197
- [lineHeight, fontSize, fontFamily],
220
+ [lineHeight, fontSize, fontFamily, tabSize],
198
221
  )
199
222
 
200
223
  // ---- Pointer events (click + drag selection) ----
@@ -236,7 +259,6 @@ export function PretextEditor({
236
259
  // ---- Keyboard ----
237
260
  const handleKeyDown = useCallback(
238
261
  (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
239
- console.log('[PretextEditor] keyDown:', e.key, 'composing=', isComposingRef.current)
240
262
  if (isComposingRef.current) return
241
263
  const ctrl = e.ctrlKey || e.metaKey
242
264
  const shift = e.shiftKey
@@ -438,7 +460,6 @@ export function PretextEditor({
438
460
  textareaRef.current = el
439
461
 
440
462
  const onBeforeInput = (e: InputEvent): void => {
441
- console.log('[PretextEditor] beforeinput: data=', e.data, 'composing=', isComposingRef.current)
442
463
  if (isComposingRef.current || !e.data) return
443
464
  e.preventDefault()
444
465