palabras: find & replace UI (Ctrl+F) — V1

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
dev 3 months ago
parent 43c1e1c6aa
commit 8c2a4d511c

@ -1158,3 +1158,71 @@ button[data-palabras-panel-crumb]:hover {
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
/* ── Find & replace — a fixed panel at the editor's top-right (Ctrl+F). soma
Words.FindReplace exposes state/methods; the open state + position are ours. ── */
[data-words-find-replace] {
position: fixed;
inset-block-start: var(--space-5);
inset-inline-end: var(--space-5);
z-index: var(--z-index-overlay-floating);
}
[data-palabras-find] {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--color-surface-overlay);
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
font-family: var(--style-label-font-family);
}
[data-palabras-find-row] {
display: flex;
align-items: center;
gap: var(--space-1);
}
[data-palabras-find-icon] {
display: inline-flex;
flex: 0 0 auto;
padding-inline: var(--space-1);
color: var(--color-content-muted);
}
[data-palabras-find-input] {
inline-size: 13rem;
min-inline-size: 0;
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--color-surface-muted);
color: var(--color-content-default);
font-size: var(--font-size-sm);
outline: none;
}
[data-palabras-find-input]:focus {
background: var(--color-surface-default);
border-color: var(--focus-ring-color);
}
[data-palabras-find][data-invalid] [data-palabras-find-row]:first-child [data-palabras-find-input] {
border-color: var(--color-threat-solid);
}
[data-palabras-find-count] {
flex: 0 0 auto;
min-inline-size: 3rem;
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-content-muted);
font-variant-numeric: tabular-nums;
}
[data-palabras-find-sep] {
flex: 0 0 auto;
inline-size: 1px;
block-size: 1.15rem;
margin-inline: var(--space-0-5);
background: var(--color-border-subtle);
}
[data-palabras-find] [data-active] {
background: var(--color-primary-element);
color: var(--color-content-primary);
}

@ -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}

@ -23,6 +23,7 @@
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
import PalabrasBubble from './palabras-bubble.svelte';
import PalabrasSlash from './palabras-slash.svelte';
import PalabrasFind from './palabras-find.svelte';
import PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte';
import PalabrasMenubar from './palabras-menubar.svelte';
@ -213,6 +214,7 @@
{#if contentEl}
<PalabrasBubble {api} />
<PalabrasSlash {api} />
<PalabrasFind content={contentEl} {dom} />
{/if}
{#if floating}
<p data-palabras-help>

Loading…
Cancel
Save

Powered by TurnKey Linux.