refactor(words): close audit debt D1 (honest cast) + D2 (dedup image picker)

- D1 (soma) block-tree.ts:104 — replace `doc as never` (which disabled all
  type checking on the descend container) with `doc as unknown as {...}`, the
  honest cast that still asserts the permissive descend-node shape
  childStacks() reads.

- D2 (eidos) — extract the duplicated ~25-line "file picker → FileReader →
  data URL" flow (copy-pasted in words-block-gutter.svelte and
  words-block-panel.svelte) into a shared `pickImageFile(doc, onPick)` helper
  in words-image-file.ts. Both consumers now call it; zero inline FileReader
  blocks remain. Kept as a plain helper (not ActiveDom) — a transient
  <input type=file> + FileReader is not instrumented by active-dom, so a
  direct Document touch is correct; the caller resolves the right Document.

Gates: npm run check 1 error (pre-existing grafito, not Words) · words soma
452/452 · prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 49802e9257
commit 1186d794cd

@ -26,6 +26,7 @@
type ProviderSnippetProps,
type WordsBlockMenuEntry
} from '$soma/components/words';
import { pickImageFile } from './words-image-file';
let {
api,
@ -206,32 +207,13 @@
// until the user picks a file or types a URL). The gutter inserter
// opens a file picker → FileReader → data URL when an image entry
// is selected, then inserts the block with a valid `src`. URLs from
// real backends would replace this with a fetch + upload.
// real backends would replace this with a fetch + upload. The picker
// flow is shared with the inspector's image panel (`pickImageFile`).
function insertImageInteractive() {
const doc =
(typeof document !== 'undefined' ? document : null) ??
content?.ownerDocument ??
null;
(typeof document !== 'undefined' ? document : null) ?? content?.ownerDocument ?? null;
if (!doc) return;
const input = doc.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = typeof reader.result === 'string' ? reader.result : '';
if (!src) return;
const alt = file.name.replace(/\.[^/.]+$/, '') || undefined;
insert({ type: 'image', src, alt });
};
reader.readAsDataURL(file);
});
doc.body.appendChild(input);
input.click();
pickImageFile(doc, (src, alt) => insert({ type: 'image', src, alt }));
}
function handleInsert(entry: WordsBlockMenuEntry) {
@ -303,7 +285,8 @@
{#if inGutter || menuOpen}
<div
data-words-block-outline
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width + 6}px; height: {rect.height + 6}px;"
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width +
6}px; height: {rect.height + 6}px;"
></div>
{/if}
<!-- Center the handle vertically against the block's height: position

@ -31,6 +31,7 @@
import Toggle from '$uix/eidos/components/toggle';
import { Switch } from '$uix/eidos/components/switch';
import WordsColorRow from './words-color-row.svelte';
import { pickImageFile } from './words-image-file';
import {
TextAlignStart,
TextAlignCenter,
@ -434,36 +435,20 @@
aria-label={t(L.ARIA_UPLOAD_IMAGE)}
title={t(L.ARIA_UPLOAD_IMAGE)}
onclick={() => {
// File picker → FileReader → data URL. Hostable apps that wire
// `onUploadImage` on the editor will see the same path used by
// drag-and-drop. Here in the panel we keep the inspector self-
// contained and write a data URL straight to `src`; consumers
// that want a backend upload can intercept by replacing the
// inspector or pre-processing the model.
// File picker → FileReader → data URL (shared with the gutter
// inserter via `pickImageFile`). Hostable apps that wire
// `onUploadImage` see the same path used by drag-and-drop;
// here in the panel we write a data URL straight to `src` to
// stay self-contained. The derived alt only fills an EMPTY alt
// (never clobbers a user-set one).
const doc = typeof document !== 'undefined' ? document : null;
if (!doc) return;
const input = doc.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = typeof reader.result === 'string' ? reader.result : '';
if (!src) return;
const altGuess = file.name.replace(/\.[^/.]+$/, '') || undefined;
patchBlock({
src,
...(block.alt === undefined && altGuess ? { alt: altGuess } : {})
});
};
reader.readAsDataURL(file);
pickImageFile(doc, (src, altGuess) => {
patchBlock({
src,
...(block.alt === undefined && altGuess ? { alt: altGuess } : {})
});
});
doc.body.appendChild(input);
input.click();
}}
>
{#snippet icon()}<Upload />{/snippet}

@ -0,0 +1,44 @@
/**
* Native image-file picker → data URL.
*
* Shared by the two Words consumers that let the user insert an image
* from disk: the gutter inserter (`words-block-gutter.svelte`) and the
* inspector's image panel upload button (`words-block-panel.svelte`).
* Both need the identical "open file dialog → FileReader → data URL"
* flow, so it lives here once instead of being copy-pasted.
*
* Kept as a plain helper (NOT routed through ActiveDom): a transient
* `<input type="file">` plus `FileReader` is not instrumented by the
* active-dom runtime, so a direct `Document` touch is correct here. The
* caller resolves the right `Document` (content's `ownerDocument`, or the
* global as a fallback) and passes it in, keeping this helper SSR-safe
* and portal/iframe-correct.
*
* `onPick` fires only when the user actually chooses a file; it receives
* the data-URL `src` and a derived `alt` (the filename without its
* extension, or `undefined` when the name is empty).
*/
export function pickImageFile(
doc: Document,
onPick: (src: string, alt: string | undefined) => void
): void {
const input = doc.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = typeof reader.result === 'string' ? reader.result : '';
if (!src) return;
const alt = file.name.replace(/\.[^/.]+$/, '') || undefined;
onPick(src, alt);
};
reader.readAsDataURL(file);
});
doc.body.appendChild(input);
input.click();
}

@ -84,11 +84,8 @@ function childStacks(node: { type?: string; [k: string]: unknown }): ChildStack[
return [];
}
function kindOf(
stack: ChildStack,
child: { type?: string }
): WordsAncestryNode['kind'] {
return stack.fixedKind ?? ((child.type as WordsBlock['type']) ?? 'column');
function kindOf(stack: ChildStack, child: { type?: string }): WordsAncestryNode['kind'] {
return stack.fixedKind ?? (child.type as WordsBlock['type']) ?? 'column';
}
/**
@ -101,17 +98,26 @@ export function findBlockById(doc: WordsDocument, idPath: string): WordsBlockLoc
const segments = idPath.split(ID_PATH_SEP).filter(Boolean);
if (segments.length === 0) return null;
let container: { type?: string; [k: string]: unknown } = doc as never;
const ancestry: WordsAncestryNode[] = [
{ id: null, idPath: '', kind: 'document', index: -1 }
];
// `childStacks` reads `type` + the container-specific child arrays
// (`children` / `columns` / `items` / `rows` / `cells`) off a permissive
// shape. `WordsDocument` carries `children` but no index signature, so it
// isn't structurally assignable — assert the descend-node shape once via
// `as unknown as` (the honest cast; `as never` disabled checking entirely).
let container: { type?: string; [k: string]: unknown } = doc as unknown as {
type?: string;
[k: string]: unknown;
};
const ancestry: WordsAncestryNode[] = [{ id: null, idPath: '', kind: 'document', index: -1 }];
const path: number[] = [];
let node: WordsBlockLocation['node'] | null = null;
for (let s = 0; s < segments.length; s++) {
const seg = segments[s];
let matched: { child: ChildStack['items'][number]; index: number; kind: WordsAncestryNode['kind'] } | null =
null;
let matched: {
child: ChildStack['items'][number];
index: number;
kind: WordsAncestryNode['kind'];
} | null = null;
for (const stack of childStacks(container)) {
const idx = stack.items.findIndex((it) => it.id === seg);
if (idx >= 0) {

Loading…
Cancel
Save

Powered by TurnKey Linux.