@pfmcodes/caret 0.2.1 → 0.2.5
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/README.md +25 -35
- package/commonjs/editor.js +169 -32
- package/commonjs/languages.js +26 -21
- package/esm/editor.js +173 -36
- package/esm/languages.js +12 -12
- package/index.css +27 -20
- package/package.json +1 -1
- package/types/editor.ts +189 -53
package/types/editor.ts
CHANGED
|
@@ -3,39 +3,45 @@ import languages from "./languages.ts";
|
|
|
3
3
|
|
|
4
4
|
languages.init();
|
|
5
5
|
|
|
6
|
-
async function createEditor(editor: HTMLElement, data:
|
|
7
|
-
const editor1
|
|
8
|
-
const highlighted
|
|
9
|
-
const caret
|
|
10
|
-
const measureCanvas
|
|
11
|
-
const measureCtx:
|
|
12
|
-
if (!measureCtx) {
|
|
13
|
-
throw new Error("Failed to get 2D context from canvas");
|
|
14
|
-
}
|
|
6
|
+
async function createEditor(editor: HTMLElement, data: any) {
|
|
7
|
+
const editor1 = document.createElement("textarea");
|
|
8
|
+
const highlighted = document.createElement("pre");
|
|
9
|
+
const caret = document.createElement("div");
|
|
10
|
+
const measureCanvas = document.createElement("canvas");
|
|
11
|
+
const measureCtx: any = measureCanvas.getContext("2d");
|
|
15
12
|
const isDark = data.theme && (data.theme.includes("dark") || data.theme.includes("night"));
|
|
16
13
|
const caretColor = isDark ? "#fff" : "#7116d8";
|
|
17
14
|
const lineColor = isDark ? "#fff" : "#000";
|
|
18
|
-
const lineCounter
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
const lineCounter = document.createElement("div");
|
|
16
|
+
|
|
17
|
+
const i = Math.random().toString(16).slice(2);
|
|
18
|
+
editor1.id = `Caret-textarea-${i}`;
|
|
19
|
+
highlighted.id = `Caret-highlighted-${i}`;
|
|
20
|
+
caret.id = `Caret-caret-${i}`;
|
|
21
|
+
lineCounter.id = `Caret-lineCounter-${i}`;
|
|
22
|
+
editor1.classList.add("Caret-textarea");
|
|
23
|
+
highlighted.classList.add("Caret-highlighted");
|
|
24
|
+
caret.classList.add("Caret-caret");
|
|
25
|
+
lineCounter.classList.add("Caret-lineCounter");
|
|
26
|
+
editor1.classList.add("dark");
|
|
27
|
+
highlighted.classList.add("dark");
|
|
28
|
+
caret.classList.add("dark");
|
|
29
|
+
lineCounter.classList.add("dark");
|
|
28
30
|
editor1.style.backgroundColor = isDark ? "#222" : "#fff";
|
|
29
31
|
let code = data.value || "";
|
|
30
32
|
let language = data.language;
|
|
31
33
|
let theme = data.theme;
|
|
34
|
+
let lock = data.readOnly || false;
|
|
35
|
+
if (lock) {
|
|
36
|
+
editor1.readOnly = true;
|
|
37
|
+
}
|
|
32
38
|
if (!languages.registeredLanguages.includes(language)) {
|
|
33
39
|
const mod = await import(`https://esm.sh/@pfmcodes/highlight.js@1.0.0/es/languages/${language}.js`);
|
|
34
40
|
languages.registerLanguage(language, mod.default);
|
|
35
41
|
languages.registeredLanguages.push(language);
|
|
36
42
|
}
|
|
37
43
|
if (theme) {
|
|
38
|
-
let themeLink
|
|
44
|
+
let themeLink = document.getElementById("Caret-theme")
|
|
39
45
|
if (!themeLink) {
|
|
40
46
|
const link = document.createElement("link");
|
|
41
47
|
link.rel = "stylesheet";
|
|
@@ -46,7 +52,7 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
46
52
|
themeLink.href = `https://esm.sh/@pfmcodes/highlight.js@1.0.0/styles/${theme}.css`;
|
|
47
53
|
}
|
|
48
54
|
} else {
|
|
49
|
-
let themeLink
|
|
55
|
+
let themeLink = document.getElementById("Caret-theme");
|
|
50
56
|
if (!themeLink) {
|
|
51
57
|
const link = document.createElement("link");
|
|
52
58
|
link.rel = "stylesheet";
|
|
@@ -58,17 +64,16 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
editor1.spellcheck = false;
|
|
61
|
-
editor1.autocapitalize = "off";
|
|
62
|
-
editor1.autocomplete = "off";
|
|
63
|
-
editor1.autocorrect =
|
|
67
|
+
(editor1 as any).autocapitalize = "off";
|
|
68
|
+
(editor1 as any).autocomplete = "off";
|
|
69
|
+
(editor1 as any).autocorrect = false;
|
|
64
70
|
editor.style = "position: relative; width: 600px; height: 300px; overflow: hidden; /* 👈 CRITICAL */ font-size: 14px;"
|
|
65
71
|
if (code && editor && editor1 && language && highlighted) {
|
|
66
72
|
editor1.style.paddingTop = "-9px";
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
highlighted.innerHTML = await _render(code, language, editor1);
|
|
73
|
+
editor1.value = data.value;
|
|
74
|
+
highlighted.innerHTML = await _render(data.value, language, editor1);
|
|
70
75
|
}
|
|
71
|
-
const keyDown = async (e:
|
|
76
|
+
const keyDown = async (e: any) => {
|
|
72
77
|
if (e.key !== "Tab") return;
|
|
73
78
|
|
|
74
79
|
e.preventDefault();
|
|
@@ -137,12 +142,15 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
function updateLineNumbers() {
|
|
140
|
-
const
|
|
145
|
+
const lines = editor1.value.split("\n");
|
|
146
|
+
const wrapMap = getWrapMap(editor1.value, 71);
|
|
141
147
|
|
|
142
148
|
let html = "";
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
lines.forEach((line, i) => {
|
|
150
|
+
const visualLines = wrapMap[i] || 1;
|
|
151
|
+
// first visual line gets the number
|
|
152
|
+
html += `<div class="Caret-lineCounter-number" style="color: ${lineColor}; height: calc(1.5em * ${visualLines})">${i + 1}</div>`;
|
|
153
|
+
});
|
|
146
154
|
|
|
147
155
|
lineCounter.innerHTML = html;
|
|
148
156
|
}
|
|
@@ -170,47 +178,92 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
170
178
|
|
|
171
179
|
editor1.addEventListener("blur", blur);
|
|
172
180
|
|
|
181
|
+
function getVisualRow(text: string, wrapAt: number) {
|
|
182
|
+
// simulate exactly what wrapCode does, but track caret position
|
|
183
|
+
const words = text.split(" ");
|
|
184
|
+
let currentLine = "";
|
|
185
|
+
let visualRow = 0;
|
|
186
|
+
|
|
187
|
+
for (let w = 0; w < words.length; w++) {
|
|
188
|
+
const word = words[w];
|
|
189
|
+
const isLast = w === words.length - 1;
|
|
190
|
+
const test = currentLine ? currentLine + " " + word : word;
|
|
191
|
+
|
|
192
|
+
if (test.length > wrapAt && currentLine !== "") {
|
|
193
|
+
visualRow++;
|
|
194
|
+
currentLine = word;
|
|
195
|
+
} else {
|
|
196
|
+
currentLine = test;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { row: visualRow, lineText: currentLine };
|
|
201
|
+
}
|
|
202
|
+
|
|
173
203
|
function updateCaret() {
|
|
174
204
|
const start = editor1.selectionStart;
|
|
175
205
|
const text = editor1.value.slice(0, start);
|
|
176
206
|
|
|
177
207
|
const lines = text.split("\n");
|
|
178
208
|
const lineIndex = lines.length - 1;
|
|
179
|
-
const
|
|
209
|
+
const currentLineText = lines[lineIndex].replace(/\t/g, " ");
|
|
210
|
+
|
|
180
211
|
|
|
181
212
|
const style = getComputedStyle(editor1);
|
|
182
213
|
const paddingLeft = parseFloat(style.paddingLeft);
|
|
183
214
|
const paddingTop = parseFloat(style.paddingTop);
|
|
184
|
-
const lineHeight = parseFloat(style.lineHeight);
|
|
215
|
+
const lineHeight = parseFloat(style.lineHeight) || 20;
|
|
185
216
|
|
|
186
217
|
updateFontMetrics();
|
|
187
|
-
const
|
|
188
|
-
|
|
218
|
+
const ascent = measureCtx.measureText("Mg").actualBoundingBoxAscent;
|
|
219
|
+
|
|
220
|
+
const WRAP_AT = 71;
|
|
221
|
+
|
|
222
|
+
// count visual rows from all previous lines
|
|
223
|
+
let totalVisualRows = 0;
|
|
224
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
225
|
+
const l = lines[i].replace(/\t/g, " ");
|
|
226
|
+
const { row } = getVisualRow(l, WRAP_AT);
|
|
227
|
+
totalVisualRows += row + 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { row, lineText } = getVisualRow(currentLineText, WRAP_AT);
|
|
231
|
+
const onWrappedLine = row > 0;
|
|
189
232
|
|
|
190
|
-
|
|
191
|
-
paddingLeft + measureCtx.measureText(lineText).width + "px";
|
|
192
|
-
caret.style.top =
|
|
193
|
-
-9 +
|
|
194
|
-
paddingTop +
|
|
195
|
-
lineIndex * lineHeight +
|
|
196
|
-
(lineHeight - ascent) +
|
|
197
|
-
"px";
|
|
233
|
+
totalVisualRows += row;
|
|
198
234
|
|
|
235
|
+
if (onWrappedLine) {
|
|
236
|
+
// on wrapped line - X is just the text on this visual row
|
|
237
|
+
|
|
238
|
+
caret.style.left = paddingLeft + measureCtx.measureText(lineText).width + "px";
|
|
239
|
+
} else {
|
|
240
|
+
// on original line - X is full line text width
|
|
241
|
+
|
|
242
|
+
caret.style.left = paddingLeft + measureCtx.measureText(lineText.trimEnd()).width + "px";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
caret.style.top = -9 + paddingTop + totalVisualRows * lineHeight + (lineHeight - ascent) + "px";
|
|
199
246
|
caret.style.height = `${lineHeight - 5}px`;
|
|
200
247
|
}
|
|
248
|
+
|
|
201
249
|
const input = async () => {
|
|
202
250
|
caret.style.opacity = "1";
|
|
203
251
|
highlighted.innerHTML = await _render(editor1.value, language, editor1);
|
|
204
252
|
updateLineNumbers();
|
|
205
253
|
updateCaret();
|
|
206
254
|
};
|
|
255
|
+
|
|
256
|
+
const onDidChangeModelContent = (fn: any) => {
|
|
257
|
+
editor1.addEventListener("input", fn)
|
|
258
|
+
}
|
|
259
|
+
|
|
207
260
|
editor1.addEventListener("input", input);
|
|
208
261
|
const scroll = async () => {
|
|
209
262
|
const x = -editor1.scrollLeft;
|
|
210
263
|
const y = -editor1.scrollTop;
|
|
211
264
|
highlighted.innerHTML = await _render(editor1.value, language, editor1);
|
|
212
|
-
highlighted.style.transform = `
|
|
213
|
-
caret.style.transform = `
|
|
265
|
+
highlighted.style.transform = `translateY(${y}px)`;
|
|
266
|
+
caret.style.transform = `translateY(${y}px)`;
|
|
214
267
|
lineCounter.style.transform = `translateY(${y}px)`;
|
|
215
268
|
};
|
|
216
269
|
editor1.addEventListener("scroll", scroll);
|
|
@@ -253,7 +306,7 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
253
306
|
l = "xml";
|
|
254
307
|
}
|
|
255
308
|
const mod = await import(`https://esm.sh/@pfmcodes/highlight.js@1.0.0/es/languages/${l}.js`);
|
|
256
|
-
|
|
309
|
+
languages.registerLanguage(l, mod)
|
|
257
310
|
}
|
|
258
311
|
language = l;
|
|
259
312
|
refresh();
|
|
@@ -263,10 +316,88 @@ async function createEditor(editor: HTMLElement, data: { value?: string, languag
|
|
|
263
316
|
setValue,
|
|
264
317
|
focus,
|
|
265
318
|
setLanguage,
|
|
266
|
-
destroy
|
|
319
|
+
destroy,
|
|
320
|
+
onDidChangeModelContent,
|
|
267
321
|
};
|
|
268
322
|
}
|
|
269
323
|
|
|
324
|
+
function wrapCode(code: string, wrapAt = 71) {
|
|
325
|
+
return code.split("\n").map(line => {
|
|
326
|
+
if (line.length <= wrapAt) return line;
|
|
327
|
+
|
|
328
|
+
const indent = line.match(/^(\s*)/)?.at(1) ?? "";
|
|
329
|
+
const words = line.trimStart().split(" ");
|
|
330
|
+
const wrappedLines = [];
|
|
331
|
+
let currentLine = indent; // first line keeps indent
|
|
332
|
+
let isFirstLine = true;
|
|
333
|
+
|
|
334
|
+
for (const word of words) {
|
|
335
|
+
if (word.length > wrapAt) {
|
|
336
|
+
if (currentLine.trim()) {
|
|
337
|
+
wrappedLines.push(currentLine);
|
|
338
|
+
currentLine = "";
|
|
339
|
+
isFirstLine = false;
|
|
340
|
+
}
|
|
341
|
+
for (let i = 0; i < word.length; i += wrapAt) {
|
|
342
|
+
wrappedLines.push(word.slice(i, i + wrapAt));
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const test = currentLine ? currentLine + " " + word : word;
|
|
348
|
+
if (test.length > wrapAt) {
|
|
349
|
+
wrappedLines.push(currentLine);
|
|
350
|
+
currentLine = word; // no indent on continuation lines
|
|
351
|
+
isFirstLine = false;
|
|
352
|
+
} else {
|
|
353
|
+
currentLine = test;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (currentLine) wrappedLines.push(currentLine);
|
|
358
|
+
return wrappedLines.join("\n");
|
|
359
|
+
}).join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getWrapMap(code: string, wrapAt = 71) {
|
|
363
|
+
const wrapMap: any = []; // wrapMap[originalLineIndex] = number of visual lines it produces
|
|
364
|
+
|
|
365
|
+
code.split("\n").forEach((line, i) => {
|
|
366
|
+
if (line.length <= wrapAt) {
|
|
367
|
+
wrapMap[i] = 1; // no wrap, 1 visual line
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const indent = line.match(/^(\s*)/)?.at(1) ?? "";
|
|
372
|
+
const words = line.trimStart().split(" ");
|
|
373
|
+
let currentLine = indent;
|
|
374
|
+
let visualLines = 1;
|
|
375
|
+
|
|
376
|
+
for (const word of words) {
|
|
377
|
+
if (word.length > wrapAt) {
|
|
378
|
+
if (currentLine.trim()) {
|
|
379
|
+
visualLines++;
|
|
380
|
+
currentLine = "";
|
|
381
|
+
}
|
|
382
|
+
visualLines += Math.floor(word.length / wrapAt);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const test = currentLine ? currentLine + " " + word : word;
|
|
387
|
+
if (test.length > wrapAt) {
|
|
388
|
+
visualLines++;
|
|
389
|
+
currentLine = word;
|
|
390
|
+
} else {
|
|
391
|
+
currentLine = test;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
wrapMap[i] = visualLines;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
return wrapMap;
|
|
399
|
+
}
|
|
400
|
+
|
|
270
401
|
function escapeHtml(str: string) {
|
|
271
402
|
return str
|
|
272
403
|
.replace(/&/g, "&")
|
|
@@ -274,12 +405,11 @@ function escapeHtml(str: string) {
|
|
|
274
405
|
.replace(/>/g, ">");
|
|
275
406
|
}
|
|
276
407
|
|
|
277
|
-
async function _render(code: string, language:
|
|
408
|
+
async function _render(code: string, language: any, editor: HTMLElement) {
|
|
278
409
|
// If no editor context provided, just highlight everything (initial load)
|
|
279
410
|
if (!editor) {
|
|
280
411
|
return hljs.highlight(code, { language }).value;
|
|
281
412
|
}
|
|
282
|
-
|
|
283
413
|
const scrollTop = editor.scrollTop;
|
|
284
414
|
const scrollBottom = scrollTop + editor.clientHeight;
|
|
285
415
|
const style = getComputedStyle(editor);
|
|
@@ -298,12 +428,18 @@ async function _render(code: string, language: string, editor: HTMLElement) {
|
|
|
298
428
|
|
|
299
429
|
// Split into three sections
|
|
300
430
|
const beforeLines = lines.slice(0, visibleStart);
|
|
301
|
-
const visibleLines = lines.slice(visibleStart, visibleEnd);
|
|
431
|
+
const visibleLines: any = lines.slice(visibleStart, visibleEnd);
|
|
302
432
|
const afterLines = lines.slice(visibleEnd);
|
|
303
433
|
|
|
304
434
|
// Only highlight visible portion
|
|
305
|
-
|
|
306
|
-
|
|
435
|
+
let wrappedCode;
|
|
436
|
+
if (visibleLines.length === 0) {
|
|
437
|
+
wrappedCode = wrapCode(code, 68);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
wrappedCode = wrapCode(visibleLines.join("\n"), 71)
|
|
441
|
+
}
|
|
442
|
+
const highlightedVisible = hljs.highlight(wrappedCode, { language }).value;
|
|
307
443
|
// Plain text for non-visible areas (no highlighting = faster)
|
|
308
444
|
if (highlightedVisible.trim() === "") {
|
|
309
445
|
return hljs.highlight(escapeHtml(code), { language }).value;
|