Editor: line numbers, wrap toggle, find bar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,47 @@ export class EditorWindow {
|
||||
readonly saving = signal(false);
|
||||
readonly closePrompt = signal(false);
|
||||
readonly sessionDead = signal(false);
|
||||
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
|
||||
* would need per-edit height measurement — not worth it without an editor lib. */
|
||||
readonly wrap = signal(false);
|
||||
readonly findOpen = signal(false);
|
||||
readonly findQuery = signal('');
|
||||
readonly activeMatch = signal(0);
|
||||
|
||||
/** Line count without allocating a split array on every keystroke. */
|
||||
readonly lineCount = computed(() => {
|
||||
const t = this.text();
|
||||
let n = 1;
|
||||
let i = -1;
|
||||
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
|
||||
return n;
|
||||
});
|
||||
readonly gutterText = computed(() =>
|
||||
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
|
||||
);
|
||||
readonly gutterDigits = computed(() => String(this.lineCount()).length);
|
||||
|
||||
/** Case-insensitive match start offsets for the find query. */
|
||||
readonly matches = computed(() => {
|
||||
const query = this.findQuery().toLowerCase();
|
||||
if (!query) return [];
|
||||
const hay = this.text().toLowerCase();
|
||||
const out: number[] = [];
|
||||
let i = hay.indexOf(query);
|
||||
while (i !== -1) {
|
||||
out.push(i);
|
||||
i = hay.indexOf(query, i + query.length);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
readonly countLabel = computed(() =>
|
||||
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
|
||||
);
|
||||
|
||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||
private cachedCharWidth = 0;
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
@@ -92,6 +131,102 @@ export class EditorWindow {
|
||||
this.scheduleDraft();
|
||||
}
|
||||
|
||||
/** Keep the line-number gutter locked to the textarea's scroll position. */
|
||||
onScroll(): void {
|
||||
const gutter = this.gutter()?.nativeElement;
|
||||
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
|
||||
}
|
||||
|
||||
toggleWrap(): void {
|
||||
this.wrap.update((w) => !w);
|
||||
queueMicrotask(() => this.onScroll());
|
||||
}
|
||||
|
||||
// ---- find ----
|
||||
|
||||
openFind(): void {
|
||||
const el = this.box()?.nativeElement;
|
||||
// prefill from the current selection when it's a sane single-line snippet
|
||||
if (el && el.selectionStart !== el.selectionEnd) {
|
||||
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
|
||||
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
|
||||
}
|
||||
this.findOpen.set(true);
|
||||
this.activeMatch.set(0);
|
||||
queueMicrotask(() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
closeFind(): void {
|
||||
this.findOpen.set(false);
|
||||
// selection was left on the match — the caret lands there
|
||||
this.box()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
onFindInput(value: string): void {
|
||||
this.findQuery.set(value);
|
||||
const list = this.matches();
|
||||
if (!list.length) return;
|
||||
// jump to the first match at/after the caret, else wrap to the first
|
||||
const from = this.box()?.nativeElement.selectionStart ?? 0;
|
||||
const at = list.findIndex((start) => start >= from);
|
||||
this.goTo(at >= 0 ? at : 0);
|
||||
}
|
||||
|
||||
findStep(delta: 1 | -1): void {
|
||||
const count = this.matches().length;
|
||||
if (!count) return;
|
||||
this.goTo((this.activeMatch() + delta + count) % count);
|
||||
}
|
||||
|
||||
onFindKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.findStep(event.shiftKey ? -1 : 1);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeFind();
|
||||
}
|
||||
}
|
||||
|
||||
/** Select match `index` and scroll it into the middle of the viewport. */
|
||||
private goTo(index: number): void {
|
||||
const el = this.box()?.nativeElement;
|
||||
const start = this.matches()[index];
|
||||
if (!el || start === undefined) return;
|
||||
this.activeMatch.set(index);
|
||||
el.setSelectionRange(start, start + this.findQuery().length);
|
||||
|
||||
const text = this.text();
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
||||
let line = 0;
|
||||
let i = text.indexOf('\n');
|
||||
while (i !== -1 && i < start) {
|
||||
line++;
|
||||
i = text.indexOf('\n', i + 1);
|
||||
}
|
||||
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
|
||||
if (!this.wrap()) {
|
||||
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
|
||||
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
|
||||
}
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
/** Monospace advance width, measured once per window. */
|
||||
private charWidth(el: HTMLTextAreaElement): number {
|
||||
if (!this.cachedCharWidth) {
|
||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||
canvas.font = getComputedStyle(el).font;
|
||||
this.cachedCharWidth = canvas.measureText('0').width || 7;
|
||||
}
|
||||
return this.cachedCharWidth;
|
||||
}
|
||||
|
||||
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
|
||||
|
||||
private scheduleDraft(): void {
|
||||
@@ -164,14 +299,23 @@ export class EditorWindow {
|
||||
if (mod && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
void this.save();
|
||||
} else if (mod && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
this.openFind();
|
||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
|
||||
// bun routes it back as editorCloseRequest
|
||||
event.preventDefault();
|
||||
this.requestClose();
|
||||
} else if (event.altKey && event.code === 'KeyZ') {
|
||||
event.preventDefault();
|
||||
this.toggleWrap();
|
||||
} else if (event.key === 'Escape' && this.closePrompt()) {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||
event.preventDefault();
|
||||
this.closeFind();
|
||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
||||
// keep Tab as an input character inside the editor
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user