Tercer hueco P0 de edición en línea. El motor traía toda la maquinaria de búsqueda (`Words.FindReplace` snippet: findQuery/findMatches/currentMatchIndex + case/word/regex/ selection + next/prev/replace/replaceAll) pero SIN chrome desde F1. `palabras-find.svelte` lo compone: panel fijo arriba-derecha con fila de búsqueda (input + contador n/total + ↑↓ + toggles Aa/palabra/regex + ✕) y fila de reemplazo (input + Reemplazar/Todo), con primitivos del framework. Apertura + posición son del chrome (el motor no las da): Ctrl/⌘F abre cuando el editor tiene foco, Esc/✕ cierra; el resaltado de coincidencias en el documento es del motor (decoraciones de F1). Cableado en TODOS los modos. Verificado Chrome real: Ctrl+F → panel arriba-derecha con input enfocado; «de» → 7 coincidencias resaltadas en amarillo + contador; Enter → 1/7 (siguiente); Reemplazar todo «downtime»→«PARADA» (verificado en el texto). check sin errores nuevos en palabras. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
43c1e1c6aa
commit
8c2a4d511c
@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Find & replace — the editor-wide search surface (INTERACCION.md; VS Code / CKE5).
|
||||
*
|
||||
* soma `Words.FindReplace` exposes the engine's find state + methods (query, match
|
||||
* list, current index, case/word/regex/selection toggles, next/prev, replace/
|
||||
* replace-all) — but NOT the open state or a position (unlike the selection-anchored
|
||||
* bubble / slash). So the chrome owns those: Ctrl/⌘F opens it (when the editor is
|
||||
* focused), Esc / ✕ closes; it floats fixed at the top-right. Match highlighting in
|
||||
* the document is the engine's (find decorations). Composed from framework Buttons.
|
||||
*/
|
||||
import { tick } from 'svelte';
|
||||
import * as Words from '$soma/components/words';
|
||||
import type { ActiveDom } from '$adom';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import {
|
||||
Search,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
CaseSensitive,
|
||||
WholeWord,
|
||||
Regex,
|
||||
Replace,
|
||||
X
|
||||
} from '$uix/eidos/components/icon';
|
||||
|
||||
let { content, dom }: { content: HTMLElement | null; dom: ActiveDom } = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const ts = (ref: string) => eidos.langs.ts(ref);
|
||||
|
||||
let open = $state(false);
|
||||
let queryInput = $state<HTMLInputElement | null>(null);
|
||||
let panelEl = $state<HTMLElement | null>(null);
|
||||
let replacement = $state('');
|
||||
|
||||
// Ctrl/⌘F opens (only while the editor — canvas or this panel — holds focus, so it
|
||||
// doesn't hijack the browser's find elsewhere on the page); Esc closes.
|
||||
$effect(() => {
|
||||
const off = dom.listen(dom.getDocument(content), 'keydown', onKey, { capture: true });
|
||||
return () => off?.();
|
||||
});
|
||||
function editorFocused(): boolean {
|
||||
const active = dom.getDocument(content).activeElement;
|
||||
return !!active && !!(content?.contains(active) || panelEl?.contains(active));
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
|
||||
if (!open && !editorFocused()) return;
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
void tick().then(() => {
|
||||
queryInput?.focus();
|
||||
queryInput?.select();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && open && panelEl?.contains(dom.getDocument(content).activeElement)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
open = false;
|
||||
content?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<Words.FindReplace>
|
||||
{#snippet children(find)}
|
||||
<div
|
||||
data-palabras-find
|
||||
bind:this={panelEl}
|
||||
data-invalid={!find.findQueryValid ? '' : undefined}
|
||||
>
|
||||
<div data-palabras-find-row>
|
||||
<span data-palabras-find-icon><Search /></span>
|
||||
<input
|
||||
bind:this={queryInput}
|
||||
type="text"
|
||||
data-palabras-find-input
|
||||
value={find.findQuery}
|
||||
placeholder={ts('#?components.palabras.find.find|Buscar…')}
|
||||
aria-label={ts('#?components.palabras.find.find|Buscar')}
|
||||
aria-invalid={!find.findQueryValid || undefined}
|
||||
oninput={(e) => find.setFindQuery(e.currentTarget.value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) find.findPrev();
|
||||
else find.findNext();
|
||||
}}
|
||||
/>
|
||||
<span data-palabras-find-count aria-live="polite">
|
||||
{#if !find.findQueryValid}
|
||||
{ts('#?components.palabras.find.invalid|!')}
|
||||
{:else if find.hasFindMatches}
|
||||
{find.currentMatchIndex + 1}/{find.findMatches.length}
|
||||
{:else}
|
||||
0/0
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
disabled={!find.hasFindMatches}
|
||||
aria-label={ts('#?components.palabras.find.prev|Anterior')}
|
||||
onclick={find.findPrev}
|
||||
>
|
||||
{#snippet icon()}<ChevronUp />{/snippet}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
disabled={!find.hasFindMatches}
|
||||
aria-label={ts('#?components.palabras.find.next|Siguiente')}
|
||||
onclick={find.findNext}
|
||||
>
|
||||
{#snippet icon()}<ChevronDown />{/snippet}
|
||||
</Button>
|
||||
<span data-palabras-find-sep></span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
data-active={find.findCaseSensitive ? '' : undefined}
|
||||
aria-pressed={find.findCaseSensitive}
|
||||
aria-label={ts('#?components.palabras.find.case|Distinguir mayúsculas')}
|
||||
onclick={() => find.setFindCaseSensitive(!find.findCaseSensitive)}
|
||||
>
|
||||
{#snippet icon()}<CaseSensitive />{/snippet}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
data-active={find.findWholeWord ? '' : undefined}
|
||||
aria-pressed={find.findWholeWord}
|
||||
aria-label={ts('#?components.palabras.find.word|Palabra completa')}
|
||||
onclick={() => find.setFindWholeWord(!find.findWholeWord)}
|
||||
>
|
||||
{#snippet icon()}<WholeWord />{/snippet}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
data-active={find.findRegex ? '' : undefined}
|
||||
aria-pressed={find.findRegex}
|
||||
aria-label={ts('#?components.palabras.find.regex|Expresión regular')}
|
||||
onclick={() => find.setFindRegex(!find.findRegex)}
|
||||
>
|
||||
{#snippet icon()}<Regex />{/snippet}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
aria-label={ts('#?components.palabras.find.close|Cerrar')}
|
||||
onclick={close}
|
||||
>
|
||||
{#snippet icon()}<X />{/snippet}
|
||||
</Button>
|
||||
</div>
|
||||
<div data-palabras-find-row>
|
||||
<span data-palabras-find-icon><Replace /></span>
|
||||
<input
|
||||
type="text"
|
||||
data-palabras-find-input
|
||||
bind:value={replacement}
|
||||
placeholder={ts('#?components.palabras.find.replace|Reemplazar…')}
|
||||
aria-label={ts('#?components.palabras.find.replace|Reemplazar')}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
find.replaceCurrentText(replacement);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!find.hasFindMatches}
|
||||
onclick={() => find.replaceCurrentText(replacement)}
|
||||
>
|
||||
{ts('#?components.palabras.find.replace-one|Reemplazar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!find.hasFindMatches}
|
||||
onclick={() => find.replaceAllText(replacement)}
|
||||
>
|
||||
{ts('#?components.palabras.find.replace-all|Todo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Words.FindReplace>
|
||||
{/if}
|
||||
Loading…
Reference in new issue