From 0b3eeab959de1cf12542b9686b91e9e8c97eb888 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 10 Oct 2026 05:08:39 +0500 Subject: [PATCH] refactor: separate schema tool state --- docs/architecture-audit-2026-10.md | 29 +-- docs/backlog.md | 6 + .../components/schema/SchemaToolView.svelte | 161 +++------------ .../schema/schema-tool-state.svelte.ts | 190 ++++++++++++++++++ 4 files changed, 235 insertions(+), 151 deletions(-) create mode 100644 web/src/lib/components/schema/schema-tool-state.svelte.ts diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index a061a0a..eb5986e 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -21,26 +21,19 @@ ## High -### H1. `SchemaToolView.svelte` — god-component — В РАБОТЕ +### H1. `SchemaToolView.svelte` — god-component — СДЕЛАНО (2026-10) -План декомпозиции (подшаги, каждый отдельным ревью-коммитом): +Компонент уменьшен со 1115 до ~660 строк (оркестратор: derived-связки, init, +handleFiles, эффекты, workspace-разметка). Декомпозиция: -- **H1a — СДЕЛАНО (2026-10):** aligned-раскладка вынесена в - `SchemaAlignedLayout.svelte` (view-only: разметка + scoped-стили, сниппет - `stepCard` остался в родителе и передаётся пропсом). Родитель уменьшился - примерно на 200 строк. -- **H1b — СДЕЛАНО (2026-10):** runner вынесен в `schema-tool-runner.svelte.ts` - (`createSchemaToolRunner(ctx)`: состояние в `$state`-объекте `RunnerState`, - действия — модульные функции run/download/ - copyText/downloadText/assignResult/toDisplayError, геттеры наружу; лимит - `max-lines-per-function` не даёт держать всё в одной фабрике). Компонент - читает состояние через `$derived`-алиасы. -- **H1c (далее):** стейт шагов (мутации `steps`, `touchedByStep`, `lastAxis`) → - `schema-tool-state.svelte.ts`. - -Исходная формулировка: ~10 ответственностей в одном компоненте — состояние -цепочки шагов, загрузка файлов, оркестрация запуска, политика скачивания, -clipboard, paste-listener, mask-превью, drag&drop, aspect-lock, две раскладки. +- **H1a:** aligned-раскладка вынесена в `SchemaAlignedLayout.svelte` (view-only: + разметка + scoped-стили, сниппет `stepCard` передаётся пропсом). +- **H1b:** runner вынесен в `schema-tool-runner.svelte.ts` + (`createSchemaToolRunner(ctx)`); идиома «`$state`-объект + модульные функции» + зафиксирована в `docs/architecture.md`, раздел 9. +- **H1c:** стейт шагов вынесен в `schema-tool-state.svelte.ts` + (`createSchemaToolState(ctx)`: steps, touchedByStep/lastAxis, все мутации, + DnD; `toolSchemaOf` — модульный экспорт). ### H2. Race condition превью — СДЕЛАНО (2026-10) diff --git a/docs/backlog.md b/docs/backlog.md index 2f14ae4..f6033a9 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -10,6 +10,12 @@ ## Pipeline +- [ ] **баг: drag-n-drop перестановки шагов не работает** — обработчики на месте + (`StepCard` draggable в сниппете `stepCard` `SchemaToolView.svelte`, + `onStepDrop` → `moveStep` в `schema-tool-state.svelte.ts`), но + перетаскивание не срабатывает. Замечено до рефактора H1 (2026-10), не + регрессия от него. Начать с проверки `draggable`/`dragstart` на `StepCard` + и того, что drop не глушится дочерними элементами карточки - [ ] названия инструментов — в pipeline инструмент называется «обрезать png», но он работает с любыми картинками, наверное надо убрать; в заголовке страницы — возможно, надо указать все форматы (связано с SEO) diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 101eeec..f78499c 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -32,11 +32,7 @@ } from "$lib/chains.svelte"; import { createChain, - createStep, - insertStep, isStructuralDefault, - moveStep, - removeStep, type ChainStep, } from "$lib/pipeline.svelte"; import { @@ -48,19 +44,20 @@ import { applySourceDefaults, clampSourceAwareMaxes, - defaultSchemaParams, - withAspectLock, - type Dimension, type ToolSchema, } from "$lib/registry-schema"; import { onMount } from "svelte"; - import { SvelteMap, SvelteSet } from "svelte/reactivity"; + import { SvelteSet } from "svelte/reactivity"; import AddStepButton from "./AddStepButton.svelte"; import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte"; import SchemaFields from "./SchemaFields.svelte"; import SchemaPreview from "./SchemaPreview.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; + import { + createSchemaToolState, + toolSchemaOf, + } from "./schema-tool-state.svelte"; interface Props { page: Page; @@ -76,12 +73,6 @@ const inputMode = $derived(tool.input); - let steps = $state([]); - // Fields edited by the user (per step): on new file load they must not be - // overwritten by source defaults. Step reset clears the mark. - const touchedByStep = new SvelteMap>(); - // Last edited axis of a dimension field; leads under lockAspect. - const lastAxis: Record = {}; let aligned = $state(false); let source = $state(null); // The full named input set (multi-upload); `source` above is the first @@ -119,7 +110,22 @@ }); return next; }); - let dragFrom = $state(null); + + const stepState = createSchemaToolState({ + source: () => source, + stepDims: () => runner.stepDims, + clearResults: () => runner.clearResults(), + }); + const { + setStepValue, + resetStep, + addStepAt, + removeStepAt, + toggleStep, + replaceStepTool, + onStepDrop, + } = stepState; + const steps = $derived(stepState.steps); const runner = createSchemaToolRunner({ pageSlug: () => page.slug, @@ -206,19 +212,6 @@ const debouncedRun = debounce(() => run(), 200); - function touchedFor(key: string): SvelteSet { - let set = touchedByStep.get(key); - if (!set) { - set = new SvelteSet(); - touchedByStep.set(key, set); - } - return set; - } - - function toolSchemaOf(step: ChainStep) { - return getTool(step.id)?.schema as ToolSchema>; - } - function stepTitle(toolId: string): string { return chainStepTitle(toolId); } @@ -232,7 +225,7 @@ runner.resetForHost(); if (chainId) { const handoff = takeHandoff(chainId); - steps = getChain(chainId)?.steps ?? createChain(page); + stepState.steps = getChain(chainId)?.steps ?? createChain(page); source = handoff?.source ?? null; sourceFiles = handoff?.sourceFiles ?? @@ -244,7 +237,7 @@ else format = lastTool.output?.mime ?? "image/png"; limitKb = handoff?.limitKb; } else { - steps = createChain(page); + stepState.steps = createChain(page); format = lastTool.output?.mime ?? "image/png"; limitKb = undefined; source = null; @@ -253,105 +246,7 @@ } sourceWarnings = []; maskOnKeys.clear(); - // Marks are keyed by step.key from the previous host; keep them and - // stale keys leak into the new chain's fields. - touchedByStep.clear(); - for (const k of Object.keys(lastAxis)) delete lastAxis[k]; - } - - function setStepValue( - index: number, - id: string, - value: unknown, - axis?: "width" | "height" | "both", - ) { - const step = steps[index]; - if (!step) return; - touchedFor(step.key).add(id); - const axisKey = `${step.key}:${id}`; - if (axis) lastAxis[axisKey] = axis === "both" ? "width" : axis; - const next: Record = { ...step.params, [id]: value }; - const stepSchema = toolSchemaOf(step); - const spec = stepSchema?.fields[id]?.spec; - // Aspect derives from the step input: source for the first step, - // previous step's result for the rest (dims known after the run). - const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1]; - if (dims && stepSchema && spec) { - const aspect = dims.width / dims.height; - if ( - spec.kind === "dimension" && - spec.lockAspectWith && - axis !== "both" && - next[spec.lockAspectWith] === true - ) { - next[id] = withAspectLock( - value as Dimension, - lastAxis[axisKey] ?? "width", - aspect, - ); - } else if (spec.kind === "checkbox" && value === true) { - // lockAspect just enabled: snap bound fields to the aspect at once. - for (const [fid, f] of Object.entries(stepSchema.fields)) { - const fs = f.spec; - if (fs.kind === "dimension" && fs.lockAspectWith === id) { - next[fid] = withAspectLock( - next[fid] as Dimension, - lastAxis[`${step.key}:${fid}`] ?? "width", - aspect, - ); - } - } - } - } - const clamped = dims ? clampSourceAwareMaxes(stepSchema, next, dims) : next; - steps = steps.with(index, { ...step, params: clamped }); - } - - function resetStep(index: number) { - const step = steps[index]; - if (!step) return; - touchedFor(step.key).clear(); - const stepSchema = toolSchemaOf(step); - const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1]; - const params = stepSchema - ? defaultSchemaParams(stepSchema, { source: dims }) - : {}; - steps = steps.with(index, { ...step, params }); - runner.clearResults(); - } - - function addStepAt(index: number, toolId: string) { - steps = insertStep(steps, index, toolId); - } - - function removeStepAt(key: string) { - steps = removeStep(steps, key); - } - - function toggleStep(index: number) { - const step = steps[index]; - if (!step) return; - steps = steps.with(index, { ...step, collapsed: !step.collapsed }); - } - - // In-place tool swap: step key and collapsed state are kept, params become - // the new tool's defaults, input is recomputed on run. - function replaceStepTool(index: number, toolId: string) { - const step = steps[index]; - const next = createStep(toolId); - if (!step || !next) return; - touchedFor(step.key).clear(); - steps = steps.with(index, { - ...next, - key: step.key, - collapsed: step.collapsed, - }); - } - - function onStepDrop(e: DragEvent, to: number) { - e.preventDefault(); - if (dragFrom !== null) steps = moveStep(steps, dragFrom, to); - dragFrom = null; + stepState.clearMarks(); } async function handleFiles(files: File[]) { @@ -382,7 +277,7 @@ const first = steps[0]; const firstSchema = first ? toolSchemaOf(first) : undefined; if (first && firstSchema) { - steps = steps.with(0, { + stepState.steps = steps.with(0, { ...first, params: clampSourceAwareMaxes( firstSchema, @@ -390,7 +285,7 @@ firstSchema, first.params, { source: firstImage }, - touchedFor(first.key), + stepState.touchedFor(first.key), ), firstImage, ), @@ -553,7 +448,7 @@ ontoggle={() => toggleStep(i)} onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined} ondragstart={(e) => { - dragFrom = i; + stepState.dragFrom = i; if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(i)); @@ -564,7 +459,7 @@ if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; }} ondrop={(e) => onStepDrop(e, i)} - ondragend={() => (dragFrom = null)} + ondragend={() => (stepState.dragFrom = null)} > {#snippet tools()} PixelImage | null; + stepDims: () => (Dimension | undefined)[]; + clearResults: () => void; +} + +interface StepsState { + steps: ChainStep[]; + dragFrom: number | null; +} + +export function toolSchemaOf(step: ChainStep) { + return getTool(step.id)?.schema as ToolSchema>; +} + +function setStepValue( + s: StepsState, + marks: Marks, + ctx: SchemaToolStateCtx, + index: number, + id: string, + value: unknown, + axis?: "width" | "height" | "both", +) { + const step = s.steps[index]; + if (!step) return; + touchedFor(marks, step.key).add(id); + const axisKey = `${step.key}:${id}`; + if (axis) marks.lastAxis[axisKey] = axis === "both" ? "width" : axis; + const next: Record = { ...step.params, [id]: value }; + const stepSchema = toolSchemaOf(step); + const spec = stepSchema?.fields[id]?.spec; + // Aspect derives from the step input: source for the first step, + // previous step's result for the rest (dims known after the run). + const dims = + index === 0 ? (ctx.source() ?? undefined) : ctx.stepDims()[index - 1]; + if (dims && stepSchema && spec) { + const aspect = dims.width / dims.height; + if ( + spec.kind === "dimension" && + spec.lockAspectWith && + axis !== "both" && + next[spec.lockAspectWith] === true + ) { + next[id] = withAspectLock( + value as Dimension, + marks.lastAxis[axisKey] ?? "width", + aspect, + ); + } else if (spec.kind === "checkbox" && value === true) { + // lockAspect just enabled: snap bound fields to the aspect at once. + for (const [fid, f] of Object.entries(stepSchema.fields)) { + const fs = f.spec; + if (fs.kind === "dimension" && fs.lockAspectWith === id) { + next[fid] = withAspectLock( + next[fid] as Dimension, + marks.lastAxis[`${step.key}:${fid}`] ?? "width", + aspect, + ); + } + } + } + } + const clamped = dims ? clampSourceAwareMaxes(stepSchema, next, dims) : next; + s.steps = s.steps.with(index, { ...step, params: clamped }); +} + +function resetStep( + s: StepsState, + marks: Marks, + ctx: SchemaToolStateCtx, + index: number, +) { + const step = s.steps[index]; + if (!step) return; + touchedFor(marks, step.key).clear(); + const stepSchema = toolSchemaOf(step); + const dims = + index === 0 ? (ctx.source() ?? undefined) : ctx.stepDims()[index - 1]; + const params = stepSchema + ? defaultSchemaParams(stepSchema, { source: dims }) + : {}; + s.steps = s.steps.with(index, { ...step, params }); + ctx.clearResults(); +} + +interface Marks { + // Fields edited by the user (per step): on new file load they must not be + // overwritten by source defaults. Step reset clears the mark. + touchedByStep: SvelteMap>; + // Last edited axis of a dimension field; leads under lockAspect. + lastAxis: Record; +} + +function touchedFor(marks: Marks, key: string): SvelteSet { + let set = marks.touchedByStep.get(key); + if (!set) { + set = new SvelteSet(); + marks.touchedByStep.set(key, set); + } + return set; +} + +// Chain-step state of SchemaToolView: the steps array, per-step edit marks +// and all mutations. Results/execution live in schema-tool-runner.svelte.ts. +export function createSchemaToolState(ctx: SchemaToolStateCtx) { + const s = $state({ steps: [], dragFrom: null }); + const marks: Marks = { + touchedByStep: new SvelteMap(), + lastAxis: {}, + }; + return { + get steps() { + return s.steps; + }, + set steps(v: ChainStep[]) { + s.steps = v; + }, + get dragFrom() { + return s.dragFrom; + }, + set dragFrom(v: number | null) { + s.dragFrom = v; + }, + touchedFor: (key: string) => touchedFor(marks, key), + // Marks are keyed by step.key from the previous host; keep them and + // stale keys leak into the new chain's fields. + clearMarks: () => { + marks.touchedByStep.clear(); + for (const k of Object.keys(marks.lastAxis)) delete marks.lastAxis[k]; + }, + setStepValue: ( + index: number, + id: string, + value: unknown, + axis?: "width" | "height" | "both", + ) => setStepValue(s, marks, ctx, index, id, value, axis), + resetStep: (index: number) => resetStep(s, marks, ctx, index), + addStepAt: (index: number, toolId: string) => { + s.steps = insertStep(s.steps, index, toolId); + }, + removeStepAt: (key: string) => { + s.steps = removeStep(s.steps, key); + }, + toggleStep: (index: number) => { + const step = s.steps[index]; + if (!step) return; + s.steps = s.steps.with(index, { + ...step, + collapsed: !step.collapsed, + }); + }, + // In-place tool swap: step key and collapsed state are kept, params + // become the new tool's defaults, input is recomputed on run. + replaceStepTool: (index: number, toolId: string) => { + const step = s.steps[index]; + const next = createStep(toolId); + if (!step || !next) return; + touchedFor(marks, step.key).clear(); + s.steps = s.steps.with(index, { + ...next, + key: step.key, + collapsed: step.collapsed, + }); + }, + onStepDrop: (e: DragEvent, to: number) => { + e.preventDefault(); + if (s.dragFrom !== null) s.steps = moveStep(s.steps, s.dragFrom, to); + s.dragFrom = null; + }, + }; +}