@jossuealcala/madre 0.7.2 → 1.0.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/CHANGELOG.md +773 -1
- package/README.md +92 -28
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +157 -81
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11207 -4196
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +2784 -1026
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1497 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6207 -425
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +2 -2
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +89 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +4 -4
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +19 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +351 -65
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1526 -163
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
package/public/editor.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
// The editor: a file of the project, written by the human's own hand.
|
|
2
|
+
//
|
|
3
|
+
// A textarea does the typing — the caret, the selection, IME, spellcheck off, the browser's own
|
|
4
|
+
// undo — and a drawing of the same text sits exactly under it with the colours, the search marks
|
|
5
|
+
// and the line the caret is on. The two never disagree because the drawing is made from the
|
|
6
|
+
// textarea's value and nothing else: no model of its own, no virtual DOM, no dependency.
|
|
7
|
+
//
|
|
8
|
+
// What it adds is what every developer reaches for without thinking: indent and outdent, a new
|
|
9
|
+
// line that keeps its indentation, brackets and quotes that close themselves, comment a line,
|
|
10
|
+
// move and duplicate lines, delete a line, select the next occurrence, find and replace, go to a
|
|
11
|
+
// line, word wrap, and the one this room exists for — ask an agent about the lines you are on.
|
|
12
|
+
//
|
|
13
|
+
// Every edit goes through `document.execCommand('insertText')` so ⌘Z and ⇧⌘Z undo and redo it the
|
|
14
|
+
// way they undo typing. Where the browser no longer offers it, `setRangeText` does the edit and
|
|
15
|
+
// undo is the browser's own business.
|
|
16
|
+
|
|
17
|
+
const PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
|
|
18
|
+
const CLOSERS = new Set([')', ']', '}', '"', "'", '`']);
|
|
19
|
+
const COMMENTS = {
|
|
20
|
+
js: { line: '//' }, css: { block: ['/*', '*/'] }, html: { block: ['<!--', '-->'] }, md: { block: ['<!--', '-->'] },
|
|
21
|
+
py: { line: '#' }, sh: { line: '#' }, yaml: { line: '#' }, sql: { line: '--' }, json: null,
|
|
22
|
+
};
|
|
23
|
+
// Past this many lines the colours step aside: the text stays exact, the drawing stays fast.
|
|
24
|
+
const COLOUR_LIMIT = 6000;
|
|
25
|
+
const isMac = () => /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '');
|
|
26
|
+
// The tools' drawings: one stroke weight, one size, the room's own line.
|
|
27
|
+
const svg = (d) => `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
28
|
+
const ICONS = {
|
|
29
|
+
find: svg('M12.4 12.4 16 16M13.6 8.6a5 5 0 1 1-10 0 5 5 0 0 1 10 0z'),
|
|
30
|
+
replace: svg('M4 7h10.5l-2.5-2.5M16 13H5.5L8 15.5'),
|
|
31
|
+
goto: svg('M4 10h9M10 6.5 13.5 10 10 13.5M16.5 4.5v11'),
|
|
32
|
+
comment: svg('M8.5 4 5.5 16M14.5 4l-3 12'),
|
|
33
|
+
wrap: svg('M3.5 5.5h13M3.5 10h10a2.6 2.6 0 0 1 0 5.2H10M11.8 13.3 10 15.2l1.8 1.8M3.5 15h3.5'),
|
|
34
|
+
ask: svg('M4 5.6A2.6 2.6 0 0 1 6.6 3h6.8A2.6 2.6 0 0 1 16 5.6v4.8A2.6 2.6 0 0 1 13.4 13H9.2L5.6 16v-3A2.6 2.6 0 0 1 4 10.4z'),
|
|
35
|
+
link: svg('M8.3 11.7l3.4-3.4M7.2 13.2 6 14.4a2.6 2.6 0 0 1-3.7-3.7l2.6-2.6a2.6 2.6 0 0 1 3.7 0M12.8 6.8 14 5.6a2.6 2.6 0 0 1 3.7 3.7l-2.6 2.6a2.6 2.6 0 0 1-3.7 0'),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Spaces or tabs, and how many: what the file already does, not what the editor prefers.
|
|
39
|
+
export function detectIndent(text) {
|
|
40
|
+
const counts = new Map();
|
|
41
|
+
let tabs = 0;
|
|
42
|
+
let spaces = 0;
|
|
43
|
+
for (const line of String(text).split('\n').slice(0, 2000)) {
|
|
44
|
+
const lead = line.match(/^[ \t]+/)?.[0];
|
|
45
|
+
if (!lead || !line.trim()) continue;
|
|
46
|
+
if (lead[0] === '\t') { tabs += 1; continue; }
|
|
47
|
+
spaces += 1;
|
|
48
|
+
const width = lead.length;
|
|
49
|
+
for (const size of [2, 4, 8]) if (width % size === 0) counts.set(size, (counts.get(size) ?? 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
if (tabs > spaces) return { tabs: true, size: 1, unit: '\t' };
|
|
52
|
+
// The largest unit every indented line is a multiple of, among the common ones.
|
|
53
|
+
const size = [4, 2].find((one) => (counts.get(one) ?? 0) >= spaces * 0.9 && spaces > 0) ?? 2;
|
|
54
|
+
return { tabs: false, size, unit: ' '.repeat(size) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The lines a selection touches, whole: from the start of the first to the end of the last.
|
|
58
|
+
export function lineSpan(value, start, end) {
|
|
59
|
+
const from = value.lastIndexOf('\n', start - 1) + 1;
|
|
60
|
+
// A selection that ends at the very start of a line does not take that line.
|
|
61
|
+
const stop = end > start && value[end - 1] === '\n' ? end - 1 : end;
|
|
62
|
+
const next = value.indexOf('\n', stop);
|
|
63
|
+
return { from, to: next === -1 ? value.length : next };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Comment or uncomment lines, the way the file's language writes a comment.
|
|
67
|
+
export function toggleComment(block, family) {
|
|
68
|
+
const style = COMMENTS[family] ?? null;
|
|
69
|
+
if (!style) return block;
|
|
70
|
+
const lines = block.split('\n');
|
|
71
|
+
if (style.line) {
|
|
72
|
+
const mark = style.line;
|
|
73
|
+
const filled = lines.filter((line) => line.trim());
|
|
74
|
+
const all = filled.length > 0 && filled.every((line) => line.trimStart().startsWith(mark));
|
|
75
|
+
if (all) return lines.map((line) => line.replace(new RegExp(`^(\\s*)${mark.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} ?`), '$1')).join('\n');
|
|
76
|
+
const indent = Math.min(...filled.map((line) => line.match(/^\s*/)[0].length));
|
|
77
|
+
return lines.map((line) => (line.trim() ? `${line.slice(0, indent)}${mark} ${line.slice(indent)}` : line)).join('\n');
|
|
78
|
+
}
|
|
79
|
+
const [open, close] = style.block;
|
|
80
|
+
const trimmed = block.trim();
|
|
81
|
+
if (trimmed.startsWith(open) && trimmed.endsWith(close)) {
|
|
82
|
+
return block.replace(open, '').replace(new RegExp(`${close.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(?![\\s\\S]*${close.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')})`), '').replace(/^(\s*) /, '$1').replace(/ (\s*)$/, '$1');
|
|
83
|
+
}
|
|
84
|
+
const lead = block.match(/^\s*/)[0];
|
|
85
|
+
return `${lead}${open} ${block.slice(lead.length)} ${close}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Where in the text a regex or a plain search matches.
|
|
89
|
+
export function findAll(text, query, { caseSensitive = false, regex = false, word = false } = {}) {
|
|
90
|
+
if (!query) return [];
|
|
91
|
+
let pattern;
|
|
92
|
+
try {
|
|
93
|
+
const source = regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
|
+
pattern = new RegExp(word ? `\\b${source}\\b` : source, caseSensitive ? 'g' : 'gi');
|
|
95
|
+
} catch { return []; }
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const match of text.matchAll(pattern)) {
|
|
98
|
+
if (!match[0]) continue;
|
|
99
|
+
found.push({ start: match.index, end: match.index + match[0].length });
|
|
100
|
+
if (found.length > 5000) break;
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createEditor({ text = '', name = '', family = null, tokenize = null, t = (s) => s, fill = null, onSave = null, onAsk = null, onDirty = null }) {
|
|
106
|
+
const say = (key, vars) => (vars && fill ? fill(t(key), vars) : t(key));
|
|
107
|
+
const crlf = /\r\n/.test(text);
|
|
108
|
+
const indent = detectIndent(text);
|
|
109
|
+
const familyId = family?.id ?? null;
|
|
110
|
+
let saved = text.replace(/\r\n/g, '\n');
|
|
111
|
+
let wrap = false;
|
|
112
|
+
let lineHeight = 21;
|
|
113
|
+
// The page's top padding: a drawn line's offset already counts it, a computed one has to add it.
|
|
114
|
+
let padTop = 14;
|
|
115
|
+
let matches = [];
|
|
116
|
+
let current = -1;
|
|
117
|
+
|
|
118
|
+
const node = document.createElement('div');
|
|
119
|
+
node.className = 'code-editor';
|
|
120
|
+
node.innerHTML = `
|
|
121
|
+
<div class="ed-bar" role="toolbar">
|
|
122
|
+
<button type="button" class="ed-btn ed-save" data-act="save"><i class="ed-dot"></i><span></span></button>
|
|
123
|
+
<span class="ed-sep"></span>
|
|
124
|
+
<span class="ed-tools">
|
|
125
|
+
<button type="button" class="ed-btn" data-act="find"><i class="ed-ico">${ICONS.find}</i><span></span></button>
|
|
126
|
+
<button type="button" class="ed-btn" data-act="replace"><i class="ed-ico">${ICONS.replace}</i><span></span></button>
|
|
127
|
+
<button type="button" class="ed-btn" data-act="goto"><i class="ed-ico">${ICONS.goto}</i><span></span></button>
|
|
128
|
+
<button type="button" class="ed-btn" data-act="comment"><i class="ed-ico">${ICONS.comment}</i><span></span></button>
|
|
129
|
+
<button type="button" class="ed-btn ed-toggle" data-act="wrap" aria-pressed="false"><i class="ed-ico">${ICONS.wrap}</i><span></span></button>
|
|
130
|
+
</span>
|
|
131
|
+
<span class="ed-grow"></span>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="ed-find" hidden>
|
|
134
|
+
<div class="ed-row">
|
|
135
|
+
<input class="ed-q" type="text" spellcheck="false" autocomplete="off">
|
|
136
|
+
<button type="button" class="ed-opt" data-opt="case" aria-pressed="false">Aa</button>
|
|
137
|
+
<button type="button" class="ed-opt" data-opt="word" aria-pressed="false">W</button>
|
|
138
|
+
<button type="button" class="ed-opt" data-opt="regex" aria-pressed="false">.*</button>
|
|
139
|
+
<span class="ed-count"></span>
|
|
140
|
+
<button type="button" class="ed-nav" data-nav="-1">↑</button>
|
|
141
|
+
<button type="button" class="ed-nav" data-nav="1">↓</button>
|
|
142
|
+
<button type="button" class="ed-x" data-act="closefind">×</button>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="ed-row ed-replace-row" hidden>
|
|
145
|
+
<input class="ed-r" type="text" spellcheck="false" autocomplete="off">
|
|
146
|
+
<button type="button" class="ed-btn" data-act="replace-one"><span></span></button>
|
|
147
|
+
<button type="button" class="ed-btn" data-act="replace-all"><span></span></button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="ed-goto" hidden><span></span><input class="ed-line" type="text" inputmode="numeric" autocomplete="off"><button type="button" class="ed-x" data-act="closegoto">×</button></div>
|
|
151
|
+
<div class="ed-main">
|
|
152
|
+
<div class="ed-gutter" aria-hidden="true"><div class="ed-gutter-inner"></div></div>
|
|
153
|
+
<div class="ed-code">
|
|
154
|
+
<div class="ed-active" aria-hidden="true"></div>
|
|
155
|
+
<div class="ed-float" hidden>
|
|
156
|
+
<button type="button" class="ed-chip ask" data-float="ask"><i class="ed-ico">${ICONS.ask}</i><span></span><b>▾</b></button>
|
|
157
|
+
<button type="button" class="ed-chip" data-float="comment"><i class="ed-ico">${ICONS.comment}</i><span></span></button>
|
|
158
|
+
<button type="button" class="ed-chip" data-float="copy"><i class="ed-ico">${ICONS.link}</i><span></span></button>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="ed-hl" aria-hidden="true"></div>
|
|
161
|
+
<textarea class="ed-input" spellcheck="false" autocapitalize="off" autocomplete="off" wrap="off"></textarea>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="ed-status"><span class="ed-pos"></span><span class="ed-sel"></span><span class="ed-keys"></span><span class="ed-grow"></span><span class="ed-indent"></span><span class="ed-eol"></span><span>UTF-8</span><span class="ed-lang"></span><span class="ed-state"></span></div>`;
|
|
165
|
+
|
|
166
|
+
const $ = (selector) => node.querySelector(selector);
|
|
167
|
+
const input = $('.ed-input');
|
|
168
|
+
const hl = $('.ed-hl');
|
|
169
|
+
const gutter = $('.ed-gutter-inner');
|
|
170
|
+
const active = $('.ed-active');
|
|
171
|
+
const findBox = $('.ed-find');
|
|
172
|
+
const query = $('.ed-q');
|
|
173
|
+
const replaceWith = $('.ed-r');
|
|
174
|
+
const gotoBox = $('.ed-goto');
|
|
175
|
+
const gotoInput = $('.ed-line');
|
|
176
|
+
const options = { case: false, word: false, regex: false };
|
|
177
|
+
const mod = isMac() ? '⌘' : 'Ctrl+';
|
|
178
|
+
|
|
179
|
+
// The words on the buttons, in the room's language.
|
|
180
|
+
// The shortcut is not printed on the button: it appears in the status line while the pointer
|
|
181
|
+
// is on the button, so the bar stays a row of words and the keys are learned where you look.
|
|
182
|
+
const label = (act, words, keys = '') => {
|
|
183
|
+
const button = node.querySelector(`[data-act="${act}"]`);
|
|
184
|
+
button.querySelector('span').textContent = words;
|
|
185
|
+
// Narrow, the bar keeps only the drawings; the name stays the button's for whoever cannot see it.
|
|
186
|
+
button.setAttribute('aria-label', words);
|
|
187
|
+
if (keys) button.dataset.keys = keys;
|
|
188
|
+
};
|
|
189
|
+
label('save', t('SAVE'), `${mod}S`);
|
|
190
|
+
label('find', t('FIND'), `${mod}F`);
|
|
191
|
+
label('replace', t('REPLACE'));
|
|
192
|
+
label('goto', t('GO TO LINE'), isMac() ? '⌃G' : 'Ctrl+G');
|
|
193
|
+
label('comment', t('COMMENT'), `${mod}/`);
|
|
194
|
+
label('wrap', t('WRAP'));
|
|
195
|
+
label('replace-one', t('REPLACE'));
|
|
196
|
+
label('replace-all', t('ALL'));
|
|
197
|
+
query.placeholder = t('Find');
|
|
198
|
+
replaceWith.placeholder = t('Replace with');
|
|
199
|
+
gotoBox.querySelector('span').textContent = t('Go to line');
|
|
200
|
+
$('.ed-indent').textContent = indent.tabs ? t('Tabs') : say('Spaces: {n}', { n: indent.size });
|
|
201
|
+
$('.ed-eol').textContent = crlf ? 'CRLF' : 'LF';
|
|
202
|
+
$('.ed-lang').textContent = family?.label ?? t('Plain text');
|
|
203
|
+
input.value = saved;
|
|
204
|
+
// A textarea given its text holds the caret after the last character; the first focus would jump
|
|
205
|
+
// there. Editing starts where reading did: at the top, or at the line asked for.
|
|
206
|
+
input.setSelectionRange?.(0, 0);
|
|
207
|
+
|
|
208
|
+
const keysLine = $('.ed-keys');
|
|
209
|
+
for (const button of node.querySelectorAll('.ed-bar [data-act]')) {
|
|
210
|
+
button.addEventListener('pointerenter', () => { keysLine.textContent = button.dataset.keys ? `${button.dataset.keys} · ${button.querySelector('span')?.textContent ?? ''}` : ''; });
|
|
211
|
+
button.addEventListener('pointerleave', () => { keysLine.textContent = ''; });
|
|
212
|
+
}
|
|
213
|
+
const floatBar = $('.ed-float');
|
|
214
|
+
floatBar.querySelector('[data-float="ask"] span').textContent = t('Ask');
|
|
215
|
+
floatBar.querySelector('[data-float="comment"] span').textContent = t('Comment');
|
|
216
|
+
floatBar.querySelector('[data-float="copy"] span').textContent = t('Copy reference');
|
|
217
|
+
floatBar.querySelector('[data-float="comment"]').hidden = !COMMENTS[familyId];
|
|
218
|
+
|
|
219
|
+
/* ---------- drawing ---------- */
|
|
220
|
+
|
|
221
|
+
const escape = (s) => s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>'));
|
|
222
|
+
// One line as HTML: the colour pieces, cut again wherever a search match starts or ends.
|
|
223
|
+
function drawLine(line, carry, offset, marks) {
|
|
224
|
+
const pieces = tokenize && family && countLines() <= COLOUR_LIMIT ? tokenize(line, family, carry) : [{ text: line, kind: null }];
|
|
225
|
+
if (!marks.length) return pieces.map((piece) => (piece.kind ? `<span class="t-${piece.kind}">${escape(piece.text)}</span>` : escape(piece.text))).join('');
|
|
226
|
+
let html = '';
|
|
227
|
+
let at = offset;
|
|
228
|
+
for (const piece of pieces) {
|
|
229
|
+
let rest = piece.text;
|
|
230
|
+
while (rest) {
|
|
231
|
+
const inside = marks.find((m) => at >= m.start && at < m.end);
|
|
232
|
+
const nextEdge = Math.min(...marks.map((m) => (m.start > at ? m.start : m.end > at ? m.end : Infinity)), at + rest.length);
|
|
233
|
+
const take = rest.slice(0, Math.max(1, nextEdge - at));
|
|
234
|
+
const body = piece.kind ? `<span class="t-${piece.kind}">${escape(take)}</span>` : escape(take);
|
|
235
|
+
html += inside ? `<mark class="${inside.cur ? 'cur' : ''}">${body}</mark>` : body;
|
|
236
|
+
at += take.length;
|
|
237
|
+
rest = rest.slice(take.length);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return html;
|
|
241
|
+
}
|
|
242
|
+
let lineCount = 1;
|
|
243
|
+
function countLines() { return lineCount; }
|
|
244
|
+
let frame = 0;
|
|
245
|
+
function draw() {
|
|
246
|
+
cancelAnimationFrame(frame);
|
|
247
|
+
frame = requestAnimationFrame(drawNow);
|
|
248
|
+
}
|
|
249
|
+
function drawNow() {
|
|
250
|
+
const value = input.value;
|
|
251
|
+
const lines = value.split('\n');
|
|
252
|
+
lineCount = lines.length;
|
|
253
|
+
const carry = { inBlock: false };
|
|
254
|
+
let offset = 0;
|
|
255
|
+
let markAt = 0;
|
|
256
|
+
const html = [];
|
|
257
|
+
const numbers = [];
|
|
258
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
259
|
+
const line = lines[i];
|
|
260
|
+
const end = offset + line.length;
|
|
261
|
+
while (markAt < matches.length && matches[markAt].end <= offset) markAt += 1;
|
|
262
|
+
const here = [];
|
|
263
|
+
for (let j = markAt; j < matches.length && matches[j].start < end; j += 1) here.push({ ...matches[j], cur: j === current });
|
|
264
|
+
html.push(`<div class="l">${drawLine(line, carry, offset, here) || ' '}</div>`);
|
|
265
|
+
numbers.push(`<div>${i + 1}</div>`);
|
|
266
|
+
offset = end + 1;
|
|
267
|
+
}
|
|
268
|
+
hl.innerHTML = html.join('');
|
|
269
|
+
gutter.innerHTML = numbers.join('');
|
|
270
|
+
if (wrap) {
|
|
271
|
+
// Wrapped, a line is as tall as it wraps: the number beside it takes the same height.
|
|
272
|
+
const rows = hl.children;
|
|
273
|
+
const nums = gutter.children;
|
|
274
|
+
for (let i = 0; i < rows.length; i += 1) nums[i].style.height = `${rows[i].offsetHeight}px`;
|
|
275
|
+
}
|
|
276
|
+
place();
|
|
277
|
+
status();
|
|
278
|
+
}
|
|
279
|
+
// The drawing follows the textarea's scroll and width, and the line the caret is on.
|
|
280
|
+
function place() {
|
|
281
|
+
hl.style.width = wrap ? `${input.clientWidth}px` : '';
|
|
282
|
+
hl.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
|
|
283
|
+
gutter.style.transform = `translateY(${-input.scrollTop}px)`;
|
|
284
|
+
const line = caretLine();
|
|
285
|
+
const row = hl.children[line - 1];
|
|
286
|
+
const top = row ? row.offsetTop : padTop + (line - 1) * lineHeight;
|
|
287
|
+
const height = row ? row.offsetHeight : lineHeight;
|
|
288
|
+
active.style.transform = `translateY(${top - input.scrollTop}px)`;
|
|
289
|
+
active.style.height = `${height}px`;
|
|
290
|
+
for (const one of gutter.querySelectorAll('.on')) one.classList.remove('on');
|
|
291
|
+
gutter.children[line - 1]?.classList.add('on');
|
|
292
|
+
placeFloat();
|
|
293
|
+
}
|
|
294
|
+
// Over the first line of what is selected, where the eye already is; under it when that line is
|
|
295
|
+
// at the very top. Gone while nothing is selected or a bar is open.
|
|
296
|
+
function placeFloat() {
|
|
297
|
+
const chosen = input.selectionEnd > input.selectionStart;
|
|
298
|
+
const show = chosen && document.activeElement === input && findBox.hidden && gotoBox.hidden;
|
|
299
|
+
floatBar.hidden = !show;
|
|
300
|
+
if (!show) return;
|
|
301
|
+
const rowAt = (line) => hl.children[line - 1];
|
|
302
|
+
const topOf = (line) => (rowAt(line) ? rowAt(line).offsetTop : padTop + (line - 1) * lineHeight) - input.scrollTop;
|
|
303
|
+
const first = caretLine(input.selectionStart);
|
|
304
|
+
const top = topOf(first);
|
|
305
|
+
const bottom = topOf(caretLine(input.selectionEnd)) + (rowAt(caretLine(input.selectionEnd))?.offsetHeight ?? lineHeight);
|
|
306
|
+
// Scrolled away entirely: nothing in sight for it to stand on.
|
|
307
|
+
if (bottom < 0 || top > input.clientHeight) { floatBar.hidden = true; return; }
|
|
308
|
+
const above = top - 38;
|
|
309
|
+
// Its first line scrolled off the top while the rest is still in sight: it waits at the edge.
|
|
310
|
+
const y = above >= 4 ? above : top >= 0 ? top + (rowAt(first)?.offsetHeight ?? lineHeight) + 6 : 4;
|
|
311
|
+
floatBar.style.transform = `translateY(${y}px)`;
|
|
312
|
+
}
|
|
313
|
+
function caretLine(at = input.selectionStart) {
|
|
314
|
+
let line = 1;
|
|
315
|
+
const value = input.value;
|
|
316
|
+
for (let i = value.indexOf('\n'); i !== -1 && i < at; i = value.indexOf('\n', i + 1)) line += 1;
|
|
317
|
+
return line;
|
|
318
|
+
}
|
|
319
|
+
function status() {
|
|
320
|
+
const value = input.value;
|
|
321
|
+
const at = input.selectionStart;
|
|
322
|
+
const line = caretLine(at);
|
|
323
|
+
const col = at - (value.lastIndexOf('\n', at - 1) + 1) + 1;
|
|
324
|
+
$('.ed-pos').textContent = say('Ln {line}, Col {col}', { line, col });
|
|
325
|
+
const chosen = input.selectionEnd - input.selectionStart;
|
|
326
|
+
$('.ed-sel').textContent = chosen ? say('{n} selected', { n: chosen }) : '';
|
|
327
|
+
const dirty = isDirty();
|
|
328
|
+
$('.ed-state').textContent = dirty ? t('Unsaved') : t('Saved');
|
|
329
|
+
node.classList.toggle('dirty', dirty);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* ---------- editing ---------- */
|
|
333
|
+
|
|
334
|
+
function isDirty() { return input.value !== saved; }
|
|
335
|
+
let wasDirty = false;
|
|
336
|
+
function changed() {
|
|
337
|
+
const dirty = isDirty();
|
|
338
|
+
if (dirty !== wasDirty) { wasDirty = dirty; onDirty?.(dirty); }
|
|
339
|
+
if (!findBox.hidden && query.value) search({ keep: true });
|
|
340
|
+
draw();
|
|
341
|
+
}
|
|
342
|
+
// Replace a range with text the way typing would, so it can be undone.
|
|
343
|
+
function edit(start, end, text, select = null) {
|
|
344
|
+
input.focus();
|
|
345
|
+
input.setSelectionRange(start, end);
|
|
346
|
+
const done = text ? document.execCommand?.('insertText', false, text) : document.execCommand?.('delete');
|
|
347
|
+
if (!done) {
|
|
348
|
+
input.setRangeText(text, start, end, 'end');
|
|
349
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
350
|
+
}
|
|
351
|
+
if (select) input.setSelectionRange(select[0], select[1]);
|
|
352
|
+
}
|
|
353
|
+
// Rewrite the whole lines a selection touches, and keep the same lines selected afterwards.
|
|
354
|
+
function rewriteLines(change) {
|
|
355
|
+
const value = input.value;
|
|
356
|
+
const { selectionStart: start, selectionEnd: end } = input;
|
|
357
|
+
const span = lineSpan(value, start, end);
|
|
358
|
+
const before = value.slice(span.from, span.to);
|
|
359
|
+
const after = change(before);
|
|
360
|
+
if (after === before) return;
|
|
361
|
+
const collapsed = start === end;
|
|
362
|
+
const delta = after.length - before.length;
|
|
363
|
+
edit(span.from, span.to, after);
|
|
364
|
+
if (collapsed) {
|
|
365
|
+
const firstLineDelta = after.split('\n')[0].length - before.split('\n')[0].length;
|
|
366
|
+
const caret = Math.max(span.from, start + firstLineDelta);
|
|
367
|
+
input.setSelectionRange(caret, caret);
|
|
368
|
+
} else input.setSelectionRange(span.from, span.to + delta);
|
|
369
|
+
}
|
|
370
|
+
const indentLines = (block) => block.split('\n').map((line) => (line.length ? indent.unit + line : line)).join('\n');
|
|
371
|
+
const outdentLines = (block) => block.split('\n').map((line) => (line.startsWith('\t') ? line.slice(1) : line.replace(new RegExp(`^ {1,${indent.size}}`), ''))).join('\n');
|
|
372
|
+
|
|
373
|
+
function moveLines(direction) {
|
|
374
|
+
const value = input.value;
|
|
375
|
+
const { selectionStart: start, selectionEnd: end } = input;
|
|
376
|
+
const span = lineSpan(value, start, end);
|
|
377
|
+
const block = value.slice(span.from, span.to);
|
|
378
|
+
if (direction < 0) {
|
|
379
|
+
if (span.from === 0) return;
|
|
380
|
+
const prevFrom = value.lastIndexOf('\n', span.from - 2) + 1;
|
|
381
|
+
const prev = value.slice(prevFrom, span.from - 1);
|
|
382
|
+
edit(prevFrom, span.to, `${block}\n${prev}`);
|
|
383
|
+
const shift = -(prev.length + 1);
|
|
384
|
+
input.setSelectionRange(start + shift, end + shift);
|
|
385
|
+
} else {
|
|
386
|
+
if (span.to >= value.length) return;
|
|
387
|
+
const nextTo = value.indexOf('\n', span.to + 1);
|
|
388
|
+
const stop = nextTo === -1 ? value.length : nextTo;
|
|
389
|
+
const next = value.slice(span.to + 1, stop);
|
|
390
|
+
edit(span.from, stop, `${next}\n${block}`);
|
|
391
|
+
const shift = next.length + 1;
|
|
392
|
+
input.setSelectionRange(start + shift, end + shift);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function duplicateLines(direction) {
|
|
396
|
+
const value = input.value;
|
|
397
|
+
const { selectionStart: start, selectionEnd: end } = input;
|
|
398
|
+
const span = lineSpan(value, start, end);
|
|
399
|
+
const block = value.slice(span.from, span.to);
|
|
400
|
+
edit(span.to, span.to, `\n${block}`);
|
|
401
|
+
if (direction > 0) input.setSelectionRange(start + block.length + 1, end + block.length + 1);
|
|
402
|
+
else input.setSelectionRange(start, end);
|
|
403
|
+
}
|
|
404
|
+
function deleteLines() {
|
|
405
|
+
const value = input.value;
|
|
406
|
+
const span = lineSpan(value, input.selectionStart, input.selectionEnd);
|
|
407
|
+
const to = span.to < value.length ? span.to + 1 : span.to;
|
|
408
|
+
const from = span.to >= value.length && span.from > 0 ? span.from - 1 : span.from;
|
|
409
|
+
edit(from, to, '');
|
|
410
|
+
}
|
|
411
|
+
// ⌘D: the word under the caret, then each next place it appears.
|
|
412
|
+
function selectNext() {
|
|
413
|
+
const value = input.value;
|
|
414
|
+
let { selectionStart: start, selectionEnd: end } = input;
|
|
415
|
+
if (start === end) {
|
|
416
|
+
while (start > 0 && /\w/.test(value[start - 1])) start -= 1;
|
|
417
|
+
while (end < value.length && /\w/.test(value[end])) end += 1;
|
|
418
|
+
input.setSelectionRange(start, end);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const word = value.slice(start, end);
|
|
422
|
+
let next = value.indexOf(word, end);
|
|
423
|
+
if (next === -1) next = value.indexOf(word);
|
|
424
|
+
if (next !== -1) { input.setSelectionRange(next, next + word.length); reveal(next); }
|
|
425
|
+
}
|
|
426
|
+
function smartHome(extend) {
|
|
427
|
+
const value = input.value;
|
|
428
|
+
const at = input.selectionStart;
|
|
429
|
+
const from = value.lastIndexOf('\n', at - 1) + 1;
|
|
430
|
+
const first = from + (value.slice(from).match(/^[ \t]*/)?.[0].length ?? 0);
|
|
431
|
+
const target = at === first ? from : first;
|
|
432
|
+
if (extend) input.setSelectionRange(Math.min(target, input.selectionEnd), input.selectionEnd, 'backward');
|
|
433
|
+
else input.setSelectionRange(target, target);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
input.addEventListener('keydown', (event) => {
|
|
437
|
+
const value = input.value;
|
|
438
|
+
const { selectionStart: start, selectionEnd: end } = input;
|
|
439
|
+
const meta = isMac() ? event.metaKey : event.ctrlKey;
|
|
440
|
+
const key = event.key;
|
|
441
|
+
const stop = () => { event.preventDefault(); event.stopPropagation(); };
|
|
442
|
+
|
|
443
|
+
if (meta && key.toLowerCase() === 's') { stop(); void save(); return; }
|
|
444
|
+
if (meta && !event.altKey && key.toLowerCase() === 'f') { stop(); openFind(false); return; }
|
|
445
|
+
if ((meta && event.altKey && key.toLowerCase() === 'f') || (!isMac() && event.ctrlKey && key.toLowerCase() === 'h')) { stop(); openFind(true); return; }
|
|
446
|
+
if (event.ctrlKey && !event.metaKey && key.toLowerCase() === 'g') { stop(); openGoto(); return; }
|
|
447
|
+
if (meta && key.toLowerCase() === 'g') { stop(); if (!findBox.hidden) step(event.shiftKey ? -1 : 1); else openGoto(); return; }
|
|
448
|
+
if (meta && key === '/') { stop(); rewriteLines((block) => toggleComment(block, familyId)); return; }
|
|
449
|
+
if (meta && key === ']') { stop(); rewriteLines(indentLines); return; }
|
|
450
|
+
if (meta && key === '[') { stop(); rewriteLines(outdentLines); return; }
|
|
451
|
+
if (meta && event.shiftKey && key.toLowerCase() === 'k') { stop(); deleteLines(); return; }
|
|
452
|
+
if (meta && key.toLowerCase() === 'd') { stop(); selectNext(); return; }
|
|
453
|
+
if (event.altKey && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
|
454
|
+
stop();
|
|
455
|
+
if (event.shiftKey) duplicateLines(key === 'ArrowDown' ? 1 : -1); else moveLines(key === 'ArrowDown' ? 1 : -1);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (key === 'Escape' && (!findBox.hidden || !gotoBox.hidden)) { stop(); closeBars(); return; }
|
|
459
|
+
if (key === 'Home' && !meta) { stop(); smartHome(event.shiftKey); status(); place(); return; }
|
|
460
|
+
|
|
461
|
+
if (key === 'Tab') {
|
|
462
|
+
stop();
|
|
463
|
+
const multi = value.slice(start, end).includes('\n');
|
|
464
|
+
if (event.shiftKey) rewriteLines(outdentLines);
|
|
465
|
+
else if (multi) rewriteLines(indentLines);
|
|
466
|
+
else edit(start, end, indent.unit);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (key === 'Enter' && !meta && !event.altKey) {
|
|
470
|
+
stop();
|
|
471
|
+
const from = value.lastIndexOf('\n', start - 1) + 1;
|
|
472
|
+
const lead = value.slice(from).match(/^[ \t]*/)[0];
|
|
473
|
+
const before = value.slice(from, start).trimEnd();
|
|
474
|
+
const opens = /[{[(]$/.test(before) || (familyId === 'py' && before.endsWith(':'));
|
|
475
|
+
const after = value[end];
|
|
476
|
+
if (opens && PAIRS[before.at(-1)] === after) {
|
|
477
|
+
const inner = `\n${lead}${indent.unit}`;
|
|
478
|
+
edit(start, end, `${inner}\n${lead}`);
|
|
479
|
+
input.setSelectionRange(start + inner.length, start + inner.length);
|
|
480
|
+
} else edit(start, end, `\n${lead}${opens ? indent.unit : ''}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (key === 'Backspace' && start === end && start > 0) {
|
|
484
|
+
const prev = value[start - 1];
|
|
485
|
+
if (PAIRS[prev] && value[start] === PAIRS[prev]) { stop(); edit(start - 1, start + 1, ''); return; }
|
|
486
|
+
// Inside the indentation, a backspace takes back one level, not one space.
|
|
487
|
+
const from = value.lastIndexOf('\n', start - 1) + 1;
|
|
488
|
+
const lead = value.slice(from, start);
|
|
489
|
+
if (!indent.tabs && lead.length >= indent.size && /^ +$/.test(lead)) {
|
|
490
|
+
stop();
|
|
491
|
+
const back = lead.length % indent.size || indent.size;
|
|
492
|
+
edit(start - back, start, '');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!meta && !event.altKey && key.length === 1) {
|
|
497
|
+
// Typing the closer that is already there steps over it.
|
|
498
|
+
if (CLOSERS.has(key) && start === end && value[start] === key) {
|
|
499
|
+
const opener = Object.keys(PAIRS).find((one) => PAIRS[one] === key);
|
|
500
|
+
if (opener === key || value.slice(0, start).includes(opener)) { stop(); input.setSelectionRange(start + 1, start + 1); status(); place(); return; }
|
|
501
|
+
}
|
|
502
|
+
if (PAIRS[key]) {
|
|
503
|
+
const quote = PAIRS[key] === key;
|
|
504
|
+
const prev = value[start - 1] ?? '';
|
|
505
|
+
const next = value[end] ?? '';
|
|
506
|
+
if (start !== end) {
|
|
507
|
+
stop();
|
|
508
|
+
const chosen = value.slice(start, end);
|
|
509
|
+
edit(start, end, `${key}${chosen}${PAIRS[key]}`, [start + 1, end + 1]);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// A quote right after a letter is an apostrophe; a pair before a word would split it.
|
|
513
|
+
if ((quote && /\w/.test(prev)) || /\w/.test(next)) return;
|
|
514
|
+
stop();
|
|
515
|
+
edit(start, end, `${key}${PAIRS[key]}`, [start + 1, start + 1]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
input.addEventListener('input', changed);
|
|
520
|
+
// Dragging selected text moves it, and reaching for lines with the mouse did exactly that by
|
|
521
|
+
// accident — a word carried into the middle of another. Text is moved here by cut and paste.
|
|
522
|
+
input.addEventListener('dragstart', (event) => event.preventDefault());
|
|
523
|
+
input.addEventListener('drop', (event) => event.preventDefault());
|
|
524
|
+
input.addEventListener('scroll', place);
|
|
525
|
+
// The wheel belongs to the page wherever it turns inside the editor. Over the numbers, the bars
|
|
526
|
+
// or the status line nothing there scrolls, and the turn used to fall through to the room behind
|
|
527
|
+
// the window; it moves the text instead. Over the text itself, the page's edge stops it (CSS).
|
|
528
|
+
node.addEventListener('wheel', (event) => {
|
|
529
|
+
if (event.ctrlKey || event.target === input) return;
|
|
530
|
+
const step = event.deltaMode === 1 ? lineHeight : event.deltaMode === 2 ? input.clientHeight : 1;
|
|
531
|
+
input.scrollTop += event.deltaY * step;
|
|
532
|
+
input.scrollLeft += event.deltaX * step;
|
|
533
|
+
event.preventDefault();
|
|
534
|
+
}, { passive: false });
|
|
535
|
+
for (const kind of ['keyup', 'click', 'select', 'focus']) input.addEventListener(kind, () => { place(); status(); });
|
|
536
|
+
const followSelection = () => { if (document.activeElement === input) { place(); status(); } };
|
|
537
|
+
document.addEventListener('selectionchange', followSelection);
|
|
538
|
+
const resized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => draw()) : null;
|
|
539
|
+
resized?.observe(input);
|
|
540
|
+
|
|
541
|
+
/* ---------- find and replace ---------- */
|
|
542
|
+
|
|
543
|
+
function search({ keep = false } = {}) {
|
|
544
|
+
matches = findAll(input.value, query.value, { caseSensitive: options.case, regex: options.regex, word: options.word });
|
|
545
|
+
if (!keep || current >= matches.length) {
|
|
546
|
+
const at = input.selectionStart;
|
|
547
|
+
current = matches.length ? Math.max(0, matches.findIndex((m) => m.start >= at)) : -1;
|
|
548
|
+
if (current === -1 && matches.length) current = 0;
|
|
549
|
+
}
|
|
550
|
+
$('.ed-count').textContent = query.value ? (matches.length ? `${current + 1}/${matches.length}` : t('no results')) : '';
|
|
551
|
+
findBox.classList.toggle('none', Boolean(query.value) && !matches.length);
|
|
552
|
+
draw();
|
|
553
|
+
}
|
|
554
|
+
function reveal(at) {
|
|
555
|
+
const line = caretLine(at);
|
|
556
|
+
const row = hl.children[line - 1];
|
|
557
|
+
const top = row ? row.offsetTop : padTop + (line - 1) * lineHeight;
|
|
558
|
+
if (top < input.scrollTop || top > input.scrollTop + input.clientHeight - lineHeight * 2) input.scrollTop = Math.max(0, top - input.clientHeight / 3);
|
|
559
|
+
}
|
|
560
|
+
function step(direction) {
|
|
561
|
+
if (!matches.length) return;
|
|
562
|
+
current = (current + direction + matches.length) % matches.length;
|
|
563
|
+
const hit = matches[current];
|
|
564
|
+
input.setSelectionRange(hit.start, hit.end);
|
|
565
|
+
reveal(hit.start);
|
|
566
|
+
$('.ed-count').textContent = `${current + 1}/${matches.length}`;
|
|
567
|
+
draw();
|
|
568
|
+
}
|
|
569
|
+
function openFind(replacing) {
|
|
570
|
+
gotoBox.hidden = true;
|
|
571
|
+
findBox.hidden = false;
|
|
572
|
+
$('.ed-replace-row').hidden = !replacing;
|
|
573
|
+
const chosen = input.value.slice(input.selectionStart, input.selectionEnd);
|
|
574
|
+
if (chosen && !chosen.includes('\n')) query.value = chosen;
|
|
575
|
+
query.focus();
|
|
576
|
+
query.select();
|
|
577
|
+
search();
|
|
578
|
+
}
|
|
579
|
+
function closeBars() {
|
|
580
|
+
findBox.hidden = true;
|
|
581
|
+
gotoBox.hidden = true;
|
|
582
|
+
matches = [];
|
|
583
|
+
current = -1;
|
|
584
|
+
draw();
|
|
585
|
+
input.focus();
|
|
586
|
+
}
|
|
587
|
+
query.addEventListener('input', () => search());
|
|
588
|
+
query.addEventListener('keydown', (event) => {
|
|
589
|
+
if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey ? -1 : 1); }
|
|
590
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeBars(); }
|
|
591
|
+
});
|
|
592
|
+
replaceWith.addEventListener('keydown', (event) => {
|
|
593
|
+
if (event.key === 'Enter') { event.preventDefault(); replaceOne(); }
|
|
594
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeBars(); }
|
|
595
|
+
});
|
|
596
|
+
for (const button of node.querySelectorAll('.ed-opt')) {
|
|
597
|
+
button.addEventListener('click', () => {
|
|
598
|
+
options[button.dataset.opt] = !options[button.dataset.opt];
|
|
599
|
+
button.setAttribute('aria-pressed', String(options[button.dataset.opt]));
|
|
600
|
+
search();
|
|
601
|
+
query.focus();
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
for (const button of node.querySelectorAll('.ed-nav')) button.addEventListener('click', () => step(Number(button.dataset.nav)));
|
|
605
|
+
function replacement(hit) {
|
|
606
|
+
if (!options.regex) return replaceWith.value;
|
|
607
|
+
try { return input.value.slice(hit.start, hit.end).replace(new RegExp(query.value, options.case ? '' : 'i'), replaceWith.value); } catch { return replaceWith.value; }
|
|
608
|
+
}
|
|
609
|
+
function replaceOne() {
|
|
610
|
+
if (!matches.length) return;
|
|
611
|
+
const hit = matches[Math.max(0, current)];
|
|
612
|
+
edit(hit.start, hit.end, replacement(hit));
|
|
613
|
+
search({ keep: true });
|
|
614
|
+
if (matches.length) step(0);
|
|
615
|
+
}
|
|
616
|
+
function replaceAll() {
|
|
617
|
+
if (!matches.length) return;
|
|
618
|
+
const value = input.value;
|
|
619
|
+
let out = '';
|
|
620
|
+
let at = 0;
|
|
621
|
+
for (const hit of matches) { out += value.slice(at, hit.start) + replacement(hit); at = hit.end; }
|
|
622
|
+
out += value.slice(at);
|
|
623
|
+
const caret = input.selectionStart;
|
|
624
|
+
edit(0, value.length, out);
|
|
625
|
+
input.setSelectionRange(Math.min(caret, out.length), Math.min(caret, out.length));
|
|
626
|
+
search();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/* ---------- go to line ---------- */
|
|
630
|
+
|
|
631
|
+
function openGoto() {
|
|
632
|
+
findBox.hidden = true;
|
|
633
|
+
gotoBox.hidden = false;
|
|
634
|
+
gotoInput.value = '';
|
|
635
|
+
gotoInput.placeholder = `1–${countLines()}`;
|
|
636
|
+
gotoInput.focus();
|
|
637
|
+
}
|
|
638
|
+
function goToLine(n) {
|
|
639
|
+
const lines = input.value.split('\n');
|
|
640
|
+
const line = Math.max(1, Math.min(lines.length, n));
|
|
641
|
+
let at = 0;
|
|
642
|
+
for (let i = 0; i < line - 1; i += 1) at += lines[i].length + 1;
|
|
643
|
+
input.focus();
|
|
644
|
+
input.setSelectionRange(at, at);
|
|
645
|
+
reveal(at);
|
|
646
|
+
place();
|
|
647
|
+
status();
|
|
648
|
+
}
|
|
649
|
+
gotoInput.addEventListener('keydown', (event) => {
|
|
650
|
+
if (event.key === 'Enter') { event.preventDefault(); const [line] = gotoInput.value.split(/[:,]/).map(Number); if (line) goToLine(line); gotoBox.hidden = true; }
|
|
651
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeBars(); }
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
/* ---------- asking about it ---------- */
|
|
655
|
+
|
|
656
|
+
// The lines the caret or the selection is on, as the room cites them.
|
|
657
|
+
function chosenLines() {
|
|
658
|
+
const lo = caretLine(input.selectionStart);
|
|
659
|
+
const hi = caretLine(Math.max(input.selectionStart, input.selectionEnd - (input.selectionEnd > input.selectionStart ? 1 : 0)));
|
|
660
|
+
return { from: lo, to: hi };
|
|
661
|
+
}
|
|
662
|
+
// Asking is the room's, not the editor's: it hands over which lines, what they say and where
|
|
663
|
+
// the button that asked stands, and the room offers who should look and what to ask — the same
|
|
664
|
+
// card it shows wherever something on the screen is asked about.
|
|
665
|
+
function askAbout(anchor) {
|
|
666
|
+
const lines = chosenLines();
|
|
667
|
+
const text = input.value.split('\n').slice(lines.from - 1, lines.to).join('\n');
|
|
668
|
+
onAsk?.({ ...lines, text, anchor: anchor.getBoundingClientRect(), dirty: isDirty() });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/* ---------- the bar ---------- */
|
|
672
|
+
|
|
673
|
+
async function save() {
|
|
674
|
+
if (!onSave) return;
|
|
675
|
+
const body = crlf ? input.value.replace(/\n/g, '\r\n') : input.value;
|
|
676
|
+
node.classList.add('saving');
|
|
677
|
+
try {
|
|
678
|
+
const ok = await onSave(body);
|
|
679
|
+
if (ok) { saved = input.value; changed(); node.classList.add('just-saved'); setTimeout(() => node.classList.remove('just-saved'), 900); }
|
|
680
|
+
} finally {
|
|
681
|
+
node.classList.remove('saving');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
node.querySelector('.ed-bar').addEventListener('click', (event) => {
|
|
685
|
+
const button = event.target.closest('[data-act]');
|
|
686
|
+
if (!button) return;
|
|
687
|
+
const act = button.dataset.act;
|
|
688
|
+
if (act === 'save') void save();
|
|
689
|
+
else if (act === 'find') openFind(false);
|
|
690
|
+
else if (act === 'replace') openFind(true);
|
|
691
|
+
else if (act === 'goto') openGoto();
|
|
692
|
+
else if (act === 'comment') { input.focus(); rewriteLines((block) => toggleComment(block, familyId)); }
|
|
693
|
+
else if (act === 'wrap') {
|
|
694
|
+
wrap = !wrap;
|
|
695
|
+
node.classList.toggle('wrap', wrap);
|
|
696
|
+
input.setAttribute('wrap', wrap ? 'soft' : 'off');
|
|
697
|
+
button.setAttribute('aria-pressed', String(wrap));
|
|
698
|
+
draw();
|
|
699
|
+
input.focus();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
floatBar.addEventListener('pointerdown', (event) => event.preventDefault());
|
|
703
|
+
floatBar.addEventListener('click', (event) => {
|
|
704
|
+
const button = event.target.closest('[data-float]');
|
|
705
|
+
if (!button) return;
|
|
706
|
+
const act = button.dataset.float;
|
|
707
|
+
if (act === 'ask') askAbout(button);
|
|
708
|
+
else if (act === 'comment') { rewriteLines((block) => toggleComment(block, familyId)); input.focus(); }
|
|
709
|
+
else if (act === 'copy') {
|
|
710
|
+
const lines = chosenLines();
|
|
711
|
+
const reference = `!${name}:${lines.from}${lines.to !== lines.from ? `-${lines.to}` : ''}`;
|
|
712
|
+
void navigator.clipboard?.writeText(reference);
|
|
713
|
+
button.classList.add('done');
|
|
714
|
+
setTimeout(() => button.classList.remove('done'), 900);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
node.querySelector('[data-act="closefind"]').addEventListener('click', closeBars);
|
|
718
|
+
node.querySelector('[data-act="closegoto"]').addEventListener('click', closeBars);
|
|
719
|
+
node.querySelector('[data-act="replace-one"]').addEventListener('click', replaceOne);
|
|
720
|
+
node.querySelector('[data-act="replace-all"]').addEventListener('click', replaceAll);
|
|
721
|
+
node.querySelector('[data-act="comment"]').disabled = !COMMENTS[familyId];
|
|
722
|
+
|
|
723
|
+
// The line height the stylesheet chose, once the editor is on the page.
|
|
724
|
+
requestAnimationFrame(() => {
|
|
725
|
+
const measured = parseFloat(getComputedStyle(input).lineHeight);
|
|
726
|
+
if (Number.isFinite(measured)) lineHeight = measured;
|
|
727
|
+
const pad = parseFloat(getComputedStyle(input).paddingTop);
|
|
728
|
+
if (Number.isFinite(pad)) padTop = pad;
|
|
729
|
+
drawNow();
|
|
730
|
+
});
|
|
731
|
+
drawNow();
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
node,
|
|
735
|
+
focus: (line = null) => { input.focus({ preventScroll: !line }); if (line) goToLine(line); },
|
|
736
|
+
value: () => input.value,
|
|
737
|
+
dirty: isDirty,
|
|
738
|
+
save,
|
|
739
|
+
// The file on disk changed under the editor and the human chose the disk's version.
|
|
740
|
+
reset(next) { saved = String(next).replace(/\r\n/g, '\n'); input.value = saved; changed(); },
|
|
741
|
+
// Taken off the page: what it listened to outside itself goes with it.
|
|
742
|
+
destroy() { document.removeEventListener('selectionchange', followSelection); resized?.disconnect(); cancelAnimationFrame(frame); },
|
|
743
|
+
};
|
|
744
|
+
}
|