palabras: copyBlock del motor — copia simétrica a pasteAfterBlock

Cierra la deuda del motor «copy = hack de outerHTML». El chrome scrapeaba el
`outerHTML` renderizado del bloque activo (con sus `data-palabras-*`) al
clipboard; ahora hay un comando simétrico que serializa desde el MODELO.

- palabras-provider.svelte.ts: método `copyBlock(index)` (async) — serializa el
  bloque top-level envuelto en un doc de 1 (`serializeHtml` → text/html limpio +
  `renderPalabrasPlainText` → text/plain) y lo escribe al clipboard realm-aware.
  Guard `typeof ClipboardItem` (fallback writeText en realm sin él). Non-mutating
  → funciona read-only. Expuesto en el api snippet + ProviderSnippetProps (types).
- palabras-menubar.svelte: `doCopy` usa `api.copyBlock(loc.path[0])` (fuera el
  `new ClipboardItem/Blob` + outerHTML); gate `!loc`; announce «Bloque copiado».
- palabras-announce.ts: `PALABRAS_ANNOUNCE.copied`.

La serialización (doc de 1 bloque) ya la cubren los tests de serialize-html /
plain-text. Langs es/en de `announce.copied` DIFERIDO (entanglement; ts fallback).

Verificado Playwright (permisos de clipboard): copiar el H1 → clipboard
`text/html` = `<h1>Cómo migrar sin downtime</h1>` LIMPIO (0 `data-palabras-*` →
es serialización del modelo, no scrape), `text/plain` correcto, announce «Bloque
copiado»; 0 errores. Ese HTML limpio es justo lo que pasteAfterBlock parsea →
round-trip simétrico. check 0 errores palabras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent b2f0a7f2bf
commit 8215a9f410

@ -34,6 +34,7 @@ export const PALABRAS_ANNOUNCE = {
movedDown: '#?components.palabras.announce.moved-down|Bloque movido abajo',
deleted: '#?components.palabras.announce.deleted|Bloque eliminado',
inserted: '#?components.palabras.announce.inserted|Bloque insertado',
copied: '#?components.palabras.announce.copied|Bloque copiado',
pasted: '#?components.palabras.announce.pasted|Bloque pegado',
cellsMerged: '#?components.palabras.announce.cells-merged|Celdas combinadas',
cellSplit: '#?components.palabras.announce.cell-split|Celda dividida'

@ -121,21 +121,12 @@
announce(direction === 'up' ? PALABRAS_ANNOUNCE.movedUp : PALABRAS_ANNOUNCE.movedDown);
}
async function doCopy() {
if (!activeEl) return;
// targetWindow-aware clipboard (iframe/popup-safe); the ClipboardItem / Blob
// constructors are realm-agnostic globals the write accepts.
const clipboard = dom.getWindow(content)?.navigator?.clipboard;
if (!clipboard) return;
try {
await clipboard.write([
new ClipboardItem({
'text/html': new Blob([activeEl.outerHTML], { type: 'text/html' }),
'text/plain': new Blob([activeEl.textContent ?? ''], { type: 'text/plain' })
})
]);
} catch {
// Clipboard unavailable / permission denied — silently skip.
}
if (!loc) return;
// Serialize the ACTIVE top-level block from the MODEL (clean semantic HTML +
// plain text), the symmetric inverse of Pegar — no more scraping rendered
// `outerHTML`. Realm-aware clipboard lives in the engine.
const ok = await api.copyBlock(loc.path[0]);
if (ok) announce(PALABRAS_ANNOUNCE.copied);
}
function doPaste() {
if (!loc) return;
@ -202,7 +193,7 @@
<span>{ts('#?components.palabras.action.move-down|Mover abajo')}</span>
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item disabled={!activeEl} onSelect={doCopy}>
<Menubar.Item disabled={!loc} onSelect={doCopy}>
<Copy />
<span>{ts('#?components.palabras.action.copy|Copiar')}</span>
</Menubar.Item>

@ -1347,6 +1347,47 @@ export class PalabrasProvider {
return changed;
}
/**
* Copy a top-level block to the system clipboard — the symmetric inverse of
* `pasteAfterBlock` (the block-menu "Copy" affordance). Serializes the block
* from the MODEL (`serializeHtml` for `text/html`, `renderPalabrasPlainText`
* for `text/plain`) rather than scraping rendered DOM, so it round-trips
* cleanly back through paste. Returns false when the block / clipboard is
* unavailable or the write is denied. Copy is non-mutating, so it works even
* read-only.
*/
async copyBlock(index: number): Promise<boolean> {
const block = this.document.children[index];
if (!block) return false;
const target = this.contentRef.current;
const win = this.soma.dom.getWindow(target);
const clipboard = win?.navigator?.clipboard;
if (!win || !clipboard) return false;
const oneBlock: PalabrasDocument = { version: this.document.version, children: [block] };
const html = serializeHtml(oneBlock);
const text = renderPalabrasPlainText(oneBlock);
try {
// `ClipboardItem` / `Blob` are realm-agnostic globals the clipboard write
// accepts (same as the old menubar copy); guarded so soma stays safe in a
// non-browser realm (node tests → no ClipboardItem → plain-text fallback).
if (typeof ClipboardItem !== 'undefined' && typeof clipboard.write === 'function') {
await clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' })
})
]);
} else if (typeof clipboard.writeText === 'function') {
await clipboard.writeText(text); // no ClipboardItem → plain text only
} else {
return false;
}
} catch {
return false;
}
return true;
}
readonly ondragover = (e: DragEvent & { currentTarget: HTMLElement }) => {
// Only intercept drags carrying image files; let everything else
// (text drags, internal selection drags) follow the browser
@ -1617,6 +1658,7 @@ export class PalabrasProvider {
runCommand: (command: PalabrasCommandName) => this.runCommandName(command),
applyCommand: (command: PalabrasCommand) => this.applyCommand(command),
pasteAfterBlock: (index: number) => this.pasteAfterBlock(index),
copyBlock: (index: number) => this.copyBlock(index),
uploadImage: (file: File, apply: (src: string, alt: string | undefined) => void) =>
this.uploadImage(file, apply),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language),

@ -244,6 +244,13 @@ export type PalabrasProviderSnippetProps = {
* the clipboard is empty / unreadable / permission-denied.
*/
readonly pasteAfterBlock: (index: number) => Promise<boolean>;
/**
* Copy the top-level block at `index` to the system clipboard (`text/html`
* from `serializeHtml` + `text/plain`) — the symmetric inverse of
* `pasteAfterBlock`. Serializes from the MODEL, not the DOM. Resolves false
* when the block / clipboard is unavailable or the write is denied.
*/
readonly copyBlock: (index: number) => Promise<boolean>;
/**
* Resolve a picked image `File` to a `src` and hand it to `apply`,
* which writes it into the document (the file-picker counterpart of

Loading…
Cancel
Save

Powered by TurnKey Linux.