diff --git a/.eslintrc b/.eslintrc index 587a36e9..6017c2ba 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,9 +6,7 @@ "prettier" ], "parserOptions": { - "project": [ - "./tsconfig.json" - ] + "project": ["./tsconfig.eslint.json"] }, "root": true, "env": { diff --git a/.storybook/main.ts b/.storybook/main.ts index e8b27c51..d6c048a0 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -25,6 +25,10 @@ const config: StorybookConfig = { typescript: { reactDocgen: "react-docgen-typescript", + reactDocgenTypescriptOptions: { + // Stories use a dedicated tsconfig with strict off (see src/stories/tsconfig.json). + tsconfigPath: "src/stories/tsconfig.json", + }, }, }; export default config; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..852c5e1a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,124 @@ +# AGENTS + + + +## Available Skills + + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to use skills: +- Invoke: `npx openskills read ` (run in your shell) + - For multiple: `npx openskills read skill-one,skill-two` +- The skill content will load with detailed instructions on how to complete the task +- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/) + +Usage notes: +- Only use skills listed in below +- Do not invoke a skill that is already loaded in your context +- Each skill invocation is stateless + + + + + +graph-dev +Development workflow for @gravity-ui/graph. Use when implementing features, fixing bugs, or designing APIs in this library. +project + + + +arc +> +global + + + +brainstorming +"You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." +global + + + +dispatching-parallel-agents +Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies +global + + + +executing-plans +Use when you have a written implementation plan to execute in a separate session with review checkpoints +global + + + +finishing-a-development-branch +Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup +global + + + +receiving-code-review +Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation +global + + + +requesting-code-review +Use when completing tasks, implementing major features, or before merging to verify work meets requirements +global + + + +subagent-driven-development +Use when executing implementation plans with independent tasks in the current session +global + + + +systematic-debugging +Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes +global + + + +test-driven-development +Use when implementing any feature or bugfix, before writing implementation code +global + + + +using-git-worktrees +Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification +global + + + +using-superpowers +Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions +global + + + +verification-before-completion +Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always +global + + + +writing-plans +Use when you have a spec or requirements for a multi-step task, before touching code +global + + + +writing-skills +Use when creating new skills, editing existing skills, or verifying skills work before deployment +global + + + + + + diff --git a/docs/analysis/typescript-strict-mode-analysis.md b/docs/analysis/typescript-strict-mode-analysis.md new file mode 100644 index 00000000..86fa713b --- /dev/null +++ b/docs/analysis/typescript-strict-mode-analysis.md @@ -0,0 +1,194 @@ +# Анализ ошибок TypeScript при включении `strict` + +Документ фиксирует объём и характер ошибок компилятора при проверке проекта в строгом режиме **без изменений в коде**. Дата снятия метрик: **2026-04-06**. + +## Методология + +- **Основной конфиг:** `tsconfig.json` задаёт `**"strict": true`** и **исключает** `src/stories`, `**/*.test.ts`, `**/*.test.tsx` (пакет без примеров и unit-тестов в этом проходе). +- **Сборка / `npm run typecheck`:** `tsconfig.publish.json` расширяет основной конфиг и **наследует** `strict`; `npm run typecheck` → `build:publish -- --noEmit` (единый строгий проход по коду пакета). +- **Stories отдельно:** в `src/stories/tsconfig.json` задано `strict: false`; подключено в Storybook (`reactDocgenTypescriptOptions.tsconfigPath`). Прогон: `npm run typecheck:stories` или `tsc --noEmit -p src/stories/tsconfig.json`. +- **Что фактически включает `--strict`** (по `tsc --showConfig`): +`strictNullChecks`, `strictFunctionTypes`, `strictBindCallApply`, `strictPropertyInitialization`, `strictBuiltinIteratorReturn`, `alwaysStrict`, `noImplicitAny`, `noImplicitThis`, `useUnknownInCatchVariables`. + +## Сводные цифры + + +| Метрика | Значение | +| ----------------------------------------------------------------- | -------- | +| **Всего диагностик** (`error TS…`) | **662** | +| **Ядро библиотеки** (`src/`, без `stories/` и без `*.test.ts(x)`) | **415** | +| **Stories** (`src/stories/`) | **216** | +| **Тесты** (`*.test.ts(x)` и пр. в `src/`) | **31** | + + +Stories дают **~33%** всех ошибок; сфокусированная миграция «только пакет» может отдельно оценивать подмножество **~415** ошибок в коде библиотеки. + +## Распределение по коду ошибки (все 662) + + +| Код | Кол-во | Кратко (что означает) | +| ------- | ------ | --------------------------------------------------------------------------------------------- | +| TS18048 | 157 | Значение **возможно `undefined`** (`?.` / проверки не сужают тип). | +| TS2322 | 125 | Тип **не присваивается** ожидаемому (часто вместе с `undefined` / `null`). | +| TS2345 | 123 | **Аргумент** не подходит под параметр (в т.ч. контравариантность `Event` vs `MouseEvent`). | +| TS2532 | 57 | Объект **возможно `undefined`** (доступ к свойству/методу). | +| TS7006 | 43 | Параметр с **неявным `any`** (`noImplicitAny`). | +| TS2564 | 32 | Поле класса **без инициализации** и без definite assignment (`strictPropertyInitialization`). | +| TS18047 | 30 | Значение **возможно `null`**. | +| TS7053 | 27 | Индексация `**string` по объекту без индексной сигнатуры** → неявный `any`. | +| TS2783 | 15 | В объектном литерале **одно и то же поле задано дважды** (часто в stories). | +| TS7005 | 10 | Переменная с **неявным `any`** (вывод типа не удался). | +| TS2538 | 9 | В качестве индекса используется `**undefined` / небезопасный ключ**. | +| TS7034 | 6 | Переменная с **неявным `any[]`** (нужна явная аннотация или инициализация). | +| TS2769 | 5 | **Нет подходящей перегрузки** (часто `addEventListener` / узкие типы событий). | +| TS2531 | 5 | Объект **возможно `null`**. | +| TS2540 | 4 | Присваивание в **только для чтения** свойство. | +| TS2722 | 2 | Вызов **возможно `undefined`**. | +| TS2488 | 2 | Ожидается итерируемое, тип **не итерируем**. | +| TS2454 | 2 | Переменная используется **до присваивания**. | +| TS2365 | 2 | Оператор **не применим** к данным типам. | +| TS2344 | 2 | Аргумент generic **не удовлетворяет ограничению**. | +| TS2339 | 2 | Свойство **отсутствует** (в т.ч. на типе `never` — логическая ошибка в ветвлении типов). | +| TS7019 | 1 | Rest-параметр с **неявным `any[]`**. | +| TS7016 | 1 | **Нет деклараций** для модуля (`style-observer` → неявный `any`). | + + +## Смысловая группировка (по типу проблемы) + +Ниже — не взаимоисключающие «корзины» (одна строка кода может порождать несколько кодов); группы удобны для планирования работ. + +### 1. Null / undefined и строгие присваивания (~70% всех диагностик по смыслу) + +- **Коды:** TS18048, TS18047, TS2532, TS2531, большая доля TS2322 и TS2345. +- **Проявления:** опциональные поля в store/API, `Array`/`Map` без гарантии элемента, цвета/строки для Canvas (`string | undefined` vs `string | CanvasGradient | CanvasPattern`), цепочки после `find`. +- **Зоны в ядре:** рендер блоков/связей, anchors, `MultipointConnection`, layers, `PublicGraphApi`. +- **Цвета и константы графа:** часть ошибок из‑за того, что `TGraphColors` описывает частичный ввод, а не смерженное состояние; см. **«План: нормализация `colors` и `constants`»**. + +### 2. Строгая инициализация полей классов + +- **Код:** TS2564 (32). +- **Примеры файлов:** `Block.ts`, `Blocks.ts`, `BaseConnection.ts`, `BlockConnection.ts` и др. +- **Суть:** поля, заполняемые позже в lifecycle, без `!`, без инициализатора и без присваивания в конструкторе. + +### 3. Неявный `any` и небезопасная индексация + +- **Коды:** TS7006, TS7005, TS7034, TS7019, TS7053. +- **Суть:** параметры без типов, «пустой» `{}` как тип состояния с динамическими ключами, индекс `string` по объектам без сигнатуры. + +### 4. События DOM и перегрузки + +- **Коды:** TS2345 (сужение `Event` → `MouseEvent`), TS2769 (`addEventListener` / union слушателей). +- **Зоны:** `Block`, `GraphComponent`, слои с HTML. + +### 5. Логика типов `never` / исчерпывающесть + +- **Коды:** TS2339 на `never`, TS2345 с `never` (например, накопление в массив без явного типа элементов). +- **Указатель на рефакторинг типов**, а не только «подправить null». + +### 6. Внешние модули без типов + +- **TS7016:** `style-observer` — нужен ambient-модуль, декларации в репозитории или типы от пакета. + +### 7. Stories: дубликаты ключей в объектах + +- **TS2783:** повторяющееся поле `config` (и аналоги) в литералах — в основном **stories**, правки обычно тривиальны. + +## Где ошибок больше всего (ядро, без stories и без `*.test`) + +Файлы с наибольшим числом диагностик (ориентир для приоритизации): + + +| Файл (путь от `src/`) | Ошибок (порядок) | +| --------------------------------------------------------------------- | ---------------- | +| `components/canvas/connections/MultipointConnection.ts` | 31 | +| `services/Layer.ts` | 21 | +| `plugins/devtools/DevToolsLayer.ts` | 20 | +| `lib/CoreComponent.ts` | 20 | +| `components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts` | 20 | +| `utils/functions/text.ts` | 19 | +| `components/canvas/blocks/Block.ts` | 19 | +| `components/canvas/layers/newBlockLayer/NewBlockLayer.ts` | 18 | +| `components/canvas/layers/belowLayer/PointerGrid.ts` | 12 | +| `components/canvas/layers/connectionLayer/ConnectionLayer.ts` | 11 | +| `components/canvas/connections/BatchPath2D/index.tsx` | 11 | + + +## Категории важности и порядок работ + +### Высокий приоритет (лучше начать с этого) + +1. **Нормализация `colors` / `constants` (см. отдельный план ниже):** единый слой «разрешённых» типов после `merge` с дефолтами — снимает большой пласт TS18048 / TS2322 / TS2532 вокруг `context.colors`, `GraphCanvas`, событий `colors-changed` и опционального `viewConfiguration.constants` в хуках. +2. **Базовые абстракции:** `CoreComponent`, `Layer` — от них зависят слои и компоненты; исправления здесь уменьшают каскад ошибок downstream. +3. **Публичный контракт:** `graph.ts`, `PublicGraphApi`, типы store (`Block`, `ConnectionState`, `ConnectionList`) — влияют на потребителей и стабильность API. +4. **Внешняя типизация:** TS7016 для `style-observer` — маленький объём, но важно для «чистого» strict без `skipLibCheck`-костылей на уровне приложения. +5. **Ошибки на `never` / массивы как `never`:** указывают на неверный вывод типов; лучше исправить рано, иначе появятся скрытые `as` и дублирование логики. + +### Средний приоритет + +1. **Рендер связей и canvas:** `MultipointConnection`, `BatchPath2D`, `BaseConnection` — много null/Canvas-строк; локальные, но объёмные (часть уйдёт после нормализации цветов, останутся геометрия и прочие `undefined`). +2. **React-обвязка:** после нормализации — точечные правки в `useGraph` (передача только определённых partial в `setConstants`), конвертеры elk — видимость для пользователей React-энтрипоинта. +3. **Сервисы:** `HitTest`, `DragService`, `SelectionService` (в тестах тоже есть ошибки) — взаимодействие и hit-test. + +### Низкий приоритет / можно отложить + +1. **Stories (~216 ошибок):** часто демо-код, дубликаты полей в конфигах, меньше риска для публикуемых типов. Имеет смысл чинить **после** или **параллельно** с ядром, либо вынести в отдельный `tsconfig` с более мягкими правилами на переходный период. +2. **Тесты (~31):** моки, частичные объекты, «удобные» утилиты — обычно последними, когда типы ядра стабилизированы. +3. **DevTools plugin:** много ошибок в одном файле, но не входит в минимальный runtime графа для потребителей — можно после стабилизации основных слоёв. + +## План: нормализация `colors` и `constants` + +Цель — совпадение **типов** с **инвариантом рантайма**: после `lodash/merge` с `initGraphColors` / `initGraphConstants` в графе всегда есть полное дерево значений, но сейчас оно типизировано как частичное (`TGraphColors` с опциональными секциями и `Partial<…>` внутри), из‑за чего strict ругается на `colors.block`, `colors.anchor`, поля для `CanvasRenderingContext2D.fillStyle` и т.д. + +### Шаг 1. Ввести типы «ввод» vs «разрешено» + + +| Роль | Назначение | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Ввод (config API)** | Оставить текущую идею: `RecursivePartial` для `setColors`, опциональные `colors` / `constants` в `viewConfiguration` и `TGraphConfig`. При необходимости явно назвать алиас, например `TGraphColorsPatch`. | +| **Разрешённое состояние** | Новый тип, например `TResolvedGraphColors`: все секции (`block`, `canvas`, `anchor`, …) **обязательны**, внутри каждой секции все поля из `TBlockColors`, `TCanvasColors`, … — **обязательные `string`** (или как в исходных интерфейсах). Построить через утилиту уровня «deep required» для фиксированного набора ключей верхнего уровня, чтобы не тащить лишние опции из patch-типа. | +| **Constants** | Аналогично: `TResolvedGraphConstants` как полностью разрешённый объект после merge с `initGraphConstants`; для API — по-прежнему `RecursivePartial` там, где допускается частичное обновление. | + + +Публичный экспорт: разрешённые типы нужны потребителям, которые читают `graph.graphColors` или подписываются на события — им должно быть ясно, что строки цветов не `undefined`. + +### Шаг 2. Функции нормализации + +- `**resolveGraphColors(patch: TGraphColors): TResolvedGraphColors`** — внутри `merge({}, initGraphColors, patch)` (или эквивалент) и возврат с типом `TResolvedGraphColors`. Одна точка правды рядом с `initGraphColors` в `graphConfig.ts` (или рядом с `Graph`). +- `**resolveGraphConstants(patch: RecursivePartial): TResolvedGraphConstants`** — то же для констант и `initGraphConstants`. + +Так рантайм не меняется концептуально: нормализация уже делается в `setColors` / `setConstants`; функции либо **оборачивают** существующий merge, либо заменяют его с явным приведением результата к разрешённому типу (после проверки, что дефолты покрывают все ключи). + +### Шаг 3. Подключить типы в состоянии графа + +- Тип значения `**$graphColors`** и геттера `**graphColors`**: `TResolvedGraphColors`. +- Тип значения `**$graphConstants`** и геттера `**graphConstants`**: `TResolvedGraphConstants`. +- Сигнатуры `**setColors` / `setConstants**`: принимают partial / recursive partial; присваивают результату нормализованное значение. +- `**LayerContext.colors**`, payload события `**colors-changed**`, аргументы колбэков в `**GraphCanvas**`: использовать разрешённый тип, чтобы не размножать `?.` и `??` по canvas-компонентам. + +### Шаг 4. Сопутствующие правки + +- `**useGraph`:** в ветках обновления view configuration передавать в сеттеры только определённые значения (`if (viewConfig.constants) graph.setConstants(viewConfig.constants)`), не пробрасывать `undefined` туда, где тип ожидает объект. +- **Экспорты пакета:** при смене типа `graphColors` проверить changelog / мажорную версию, если сигнатуры публичных типов меняются с «всё опционально» на «всё задано». + +### Ожидаемый эффект + +- Уходит дублирование дефолтов через `??` в каждом месте чтения цвета. +- Секция **«Null / undefined»** в плане для цветов/констант смещается с «точечные guard’ы» на «один слой нормализации + строгие типы состояния». + +--- + +## Практические рекомендации по миграции (без правок кода в этом шаге) + +- **Поэтапное включение в `tsconfig`:** при необходимости сначала отдельные флаги (`strictNullChecks`, затем `strictPropertyInitialization`, затем полный `strict`) снижают шок от объёма; основной и publish-конфиги с `strict: true`, прогресс смотрите через `npm run typecheck`. +- **Разделение проектов:** `src/`** под strict, stories — отдельная компиляция или отложенный этап, чтобы не блокировать библиотеку. +- **Colors / constants:** следовать разделу **«План: нормализация `colors` и `constants`»** до массового добавления `?.`/`??` в рендер. +- **Паттерны исправлений (ожидаемые):** сужение типов guard’ами, `??`/`!` только там, где есть инвариант, инициализаторы полей или `declare`/assign в `init`, явные типы вместо неявного `any`, для событий — слушатели с `Event` + `instanceof` или обёртки, для динамических ключей — `Record<…>` / mapped types вместо `{}`. + +## Артефакт проверки + +Полный лог компилятора можно воспроизвести командой выше; при необходимости сохранить вывод: +`npm run typecheck 2>&1 | tee typescript-strict-errors.log`. + +--- + +*Документ подготовлен как входная точка для плана включения strict mode; код репозитория на момент анализа не изменялся.* \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index 0b430d0f..96e10335 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,7 +1,8 @@ import type { Config } from "jest"; const jestConfig: Config = { - testPathIgnorePatterns: ["/node_modules/", "/e2e/"], + // Ignore compiled `build/` output: tests must live under `src/` to avoid duplicates and stale suites. + testPathIgnorePatterns: ["/node_modules/", "/e2e/", "/build/"], testEnvironment: "jsdom", setupFiles: ["/setupJest.js", "jest-canvas-mock"], transformIgnorePatterns: [], diff --git a/package.json b/package.json index 9b427a74..0ffb7117 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "scripts": { "typecheck": "npm run build:publish -- --noEmit", + "typecheck:stories": "tsc --noEmit -p src/stories/tsconfig.json", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "test:unit": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --updateSnapshot", "test": "npm run test:unit", diff --git a/src/api/PublicGraphApi.ts b/src/api/PublicGraphApi.ts index 3ee7cb34..a473eefe 100644 --- a/src/api/PublicGraphApi.ts +++ b/src/api/PublicGraphApi.ts @@ -3,14 +3,16 @@ import { batch } from "@preact/signals-core"; import { GraphComponent } from "../components/canvas/GraphComponent"; import { TBlock } from "../components/canvas/blocks/Block"; import { Graph } from "../graph"; -import { TGraphColors, TGraphConstants } from "../graphConfig"; +import { TGraphColors, TGraphConstants, TResolvedGraphColors } from "../graphConfig"; import { ESelectionStrategy } from "../services/selection/types"; import { TBlockId } from "../store/block/Block"; import { selectBlockById } from "../store/block/selectors"; import { TConnection, TConnectionId } from "../store/connection/ConnectionState"; import { selectConnectionById } from "../store/connection/selectors"; import { TGraphSettingsConfig } from "../store/settings"; +import { logDev } from "../utils/devLog"; import { getBlocksRect, getElementsRect, startAnimation } from "../utils/functions"; +import type { RecursivePartial } from "../utils/types/helpers"; import { TRect } from "../utils/types/shapes"; export type ZoomConfig = { @@ -143,11 +145,11 @@ export class PublicGraphApi { }); } - public getGraphColors(): TGraphColors { + public getGraphColors(): TResolvedGraphColors { return this.graph.graphColors; } - public updateGraphColors(colors: TGraphColors) { + public updateGraphColors(colors: RecursivePartial): void { this.graph.setColors(colors); } @@ -155,7 +157,7 @@ export class PublicGraphApi { return this.graph.graphConstants; } - public updateGraphConstants(constants: TGraphConstants) { + public updateGraphConstants(constants: RecursivePartial): void { this.graph.setConstants(constants); } @@ -225,8 +227,12 @@ export class PublicGraphApi { }); } - public updateConnection(id: TConnectionId, connection: Partial) { + public updateConnection(id: TConnectionId, connection: Partial): void { const connectionStore = selectConnectionById(this.graph, id); + if (!connectionStore) { + logDev(`updateConnection: connection not found: ${String(id)}`); + return; + } connectionStore.updateConnection(connection); } diff --git a/src/components/canvas/EventedComponent/EventedComponent.ts b/src/components/canvas/EventedComponent/EventedComponent.ts index 7ea3cc56..f69c6ae9 100644 --- a/src/components/canvas/EventedComponent/EventedComponent.ts +++ b/src/components/canvas/EventedComponent/EventedComponent.ts @@ -33,11 +33,13 @@ export class EventedComponent< this.setProps({ interactive }); } - private get events() { - if (!listeners.has(this)) { - listeners.set(this, new Map()); + private get events(): Map> { + let map = listeners.get(this); + if (!map) { + map = new Map(); + listeners.set(this, map); } - return listeners.get(this); + return map; } protected unmount() { diff --git a/src/components/canvas/GraphComponent/GraphComponent.test.ts b/src/components/canvas/GraphComponent/GraphComponent.test.ts index e22f183c..575ca68d 100644 --- a/src/components/canvas/GraphComponent/GraphComponent.test.ts +++ b/src/components/canvas/GraphComponent/GraphComponent.test.ts @@ -1,7 +1,12 @@ -import { Graph } from "../../../graph"; -import { GraphEventsDefinitions } from "../../../graphEvents"; -import { Component } from "../../../lib/Component"; +import type { Graph } from "../../../graph"; +import { createInitialResolvedGraphColors, initGraphConstants } from "../../../graphConfig"; +import { GraphEventListener, GraphEventsDefinitions } from "../../../graphEvents"; +import { Component, TComponentState } from "../../../lib/Component"; +import type { CoreComponentProps } from "../../../lib/CoreComponent"; +import { CoreComponent } from "../../../lib/CoreComponent"; import { HitBox } from "../../../services/HitTest"; +import type { Layer } from "../../../services/Layer"; +import type { ICamera } from "../../../services/camera/CameraService"; import { GraphComponent, GraphComponentContext } from "./index"; @@ -12,7 +17,7 @@ class TestGraphComponent extends GraphComponent { public subscribeGraphEvent( eventName: EventName, - handler: GraphEventsDefinitions[EventName], + handler: GraphEventListener, options?: AddEventListenerOptions | boolean ): () => void { return this.onGraphEvent(eventName, handler, options); @@ -46,28 +51,38 @@ function createTestComponent(root?: HTMLDivElement): TestSetup { remove: hitTestRemove, update: jest.fn(), }, - // The rest of Graph API is not needed for these tests }; const rootEl = root ?? document.createElement("div"); + const canvasEl = document.createElement("canvas"); + const ctx = canvasEl.getContext("2d"); + if (!ctx) { + throw new Error("Canvas 2D context is required for GraphComponent tests"); + } + + const rootHost = new CoreComponent({}, undefined); + const parent = new Component({}, rootHost); + + const minimalCamera: Pick = { + isRectVisible: () => true, + }; - const parent = new Component({}, undefined); + const layerPlaceholder = {} as Layer; parent.setContext({ + // @ts-expect-error — test stub: partial Graph (only on / hitTest are used) graph: fakeGraph, root: rootEl, - canvas: document.createElement("canvas"), - ctx: document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D, + canvas: canvasEl, + ctx, ownerDocument: document, - camera: { - isRectVisible: () => true, - }, - constants: {} as GraphComponentContext["constants"], - colors: {} as GraphComponentContext["colors"], - graphCanvas: document.createElement("canvas"), - layer: {} as GraphComponentContext["layer"], + camera: minimalCamera as ICamera, + constants: initGraphConstants, + colors: createInitialResolvedGraphColors(), + graphCanvas: canvasEl, + layer: layerPlaceholder, affectsUsableRect: true, - } as unknown as GraphComponentContext); + }); const component = new TestGraphComponent({}, parent); @@ -104,7 +119,6 @@ describe("GraphComponent event helpers", () => { const { component } = createTestComponent(rootEl); const handler = jest.fn((event: MouseEvent) => { - // Use event to keep types happy expect(event).toBeInstanceOf(MouseEvent); }); diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index c33921bc..d03dab02 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -1,7 +1,7 @@ import { Signal } from "@preact/signals-core"; import { Graph } from "../../../graph"; -import { GraphEventsDefinitions } from "../../../graphEvents"; +import { GraphEventListener, GraphEventsDefinitions } from "../../../graphEvents"; import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; @@ -111,10 +111,11 @@ export class GraphComponent< } public getPort(id: TPortId): PortState { - if (!this.ports.has(id)) { - return this.createPort(id); + const existing = this.ports.get(id); + if (existing !== undefined) { + return existing; } - return this.ports.get(id); + return this.createPort(id); } /** @@ -142,7 +143,7 @@ export class GraphComponent< protected propsChanged(_nextProps: Props): void { if (this.affectsUsableRect !== _nextProps.affectsUsableRect) { - this.hitBox.setAffectsUsableRect(_nextProps.affectsUsableRect); + this.hitBox.setAffectsUsableRect(_nextProps.affectsUsableRect ?? true); this.setContext({ affectsUsableRect: _nextProps.affectsUsableRect }); } super.propsChanged(_nextProps); @@ -154,7 +155,7 @@ export class GraphComponent< this.firstRender || (this.context.affectsUsableRect !== _nextContext.affectsUsableRect && this.props.affectsUsableRect === undefined) ) { - this.hitBox.setAffectsUsableRect(_nextContext.affectsUsableRect); + this.hitBox.setAffectsUsableRect(_nextContext.affectsUsableRect ?? true); } super.contextChanged(_nextContext); } @@ -199,44 +200,51 @@ export class GraphComponent< autopanning?: boolean; dragCursor?: CursorLayerCursorTypes; }) { - let startCoords: [number, number]; - let prevCoords: [number, number]; - return this.addEventListener("mousedown", (event: MouseEvent) => { + let startCoords: [number, number] | undefined; + let prevCoords: [number, number] | undefined; + return this.addEventListener("mousedown", (event: Event) => { + if (!(event instanceof MouseEvent)) { + return; + } if (!isDraggable?.(event)) { return; } event.stopPropagation(); this.context.graph.dragService.startDrag( { - onStart: (event: MouseEvent) => { - if (onDragStart?.(event) === false) { + onStart: (startEvent: MouseEvent) => { + if (onDragStart?.(startEvent) === false) { return; } - const xy = getXY(this.context.canvas, event); + const xy = getXY(this.context.canvas, startEvent); startCoords = this.context.camera.applyToPoint(xy[0], xy[1]); prevCoords = startCoords; }, - onUpdate: (event: MouseEvent) => { - if (!startCoords?.length) return; + onUpdate: (updateEvent: MouseEvent) => { + if (startCoords === undefined || prevCoords === undefined) { + return; + } - const [canvasX, canvasY] = getXY(this.context.canvas, event); + const [canvasX, canvasY] = getXY(this.context.canvas, updateEvent); const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); + const prev = prevCoords; + // Absolute diff from drag start const diffX = currentCoords[0] - startCoords[0]; const diffY = currentCoords[1] - startCoords[1]; // Incremental diff from previous frame - const deltaX = currentCoords[0] - prevCoords[0]; - const deltaY = currentCoords[1] - prevCoords[1]; + const deltaX = currentCoords[0] - prev[0]; + const deltaY = currentCoords[1] - prev[1]; - onDragUpdate?.({ startCoords, prevCoords, currentCoords, diffX, diffY, deltaX, deltaY }, event); + onDragUpdate?.({ startCoords, prevCoords: prev, currentCoords, diffX, diffY, deltaX, deltaY }, updateEvent); prevCoords = currentCoords; }, - onEnd: (event: MouseEvent) => { + onEnd: (endEvent: MouseEvent) => { startCoords = undefined; prevCoords = undefined; - onDrop?.(event); + onDrop?.(endEvent); }, }, { @@ -268,9 +276,9 @@ export class GraphComponent< * @param options - Additional AddEventListener options * @returns Unsubscribe function */ - protected onGraphEvent( + protected onGraphEvent( eventName: EventName, - handler: Cb, + handler: GraphEventListener, options?: AddEventListenerOptions | boolean ): () => void { const unsubscribe = this.context.graph.on(eventName, handler, options); @@ -296,14 +304,12 @@ export class GraphComponent< throw new Error("Attempt to add event listener to non-existent root element"); } - const listener = - typeof handler === "function" - ? (handler as (this: HTMLElement, ev: HTMLElementEventMap[K]) => void) - : (handler as EventListenerObject); + const listener: EventListenerOrEventListenerObject = + typeof handler === "function" ? (handler as EventListener) : (handler as EventListenerObject); root.addEventListener(eventName, listener, options); - const unsubscribe = () => { + const unsubscribe = (): void => { root.removeEventListener(eventName, listener, options); }; diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index d7b1bb27..33773c2b 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -4,6 +4,7 @@ import { AnchorState, EAnchorType } from "../../../store/anchor/Anchor"; import { TBlockId } from "../../../store/block/Block"; import { selectBlockAnchor } from "../../../store/block/selectors"; import { PortState } from "../../../store/connection/port/Port"; +import { logDev } from "../../../utils/devLog"; import { GraphComponent, TGraphComponentProps } from "../GraphComponent"; import { GraphLayer } from "../layers/graphLayer/GraphLayer"; @@ -44,7 +45,7 @@ export class Anchor extends GraphComponen return this.__comp.parent.zIndex + 1; } - public connectedState: AnchorState; + public connectedState?: AnchorState; private shift = 0; @@ -52,8 +53,13 @@ export class Anchor extends GraphComponen super(props, parent); this.state = { size: props.size, raised: false, selected: false }; - this.connectedState = selectBlockAnchor(this.context.graph, props.blockId, props.id); - this.connectedState.setViewComponent(this); + const anchorState = selectBlockAnchor(this.context.graph, props.blockId, props.id); + if (!anchorState) { + logDev(`Anchor not found: block "${String(props.blockId)}", anchor "${String(props.id)}"`); + } else { + this.connectedState = anchorState; + this.connectedState.setViewComponent(this); + } this.addEventListener("click", this); this.addEventListener("mouseenter", this); @@ -91,9 +97,11 @@ export class Anchor extends GraphComponen protected willMount(): void { this.props.port.setOwner(this); - this.subscribeSignal(this.connectedState.$selected, (selected) => { - this.setState({ selected }); - }); + if (this.connectedState) { + this.subscribeSignal(this.connectedState.$selected, (selected) => { + this.setState({ selected }); + }); + } this.subscribeSignal(this.props.port.$point, this.onPositionChanged); this.computeShift(this.state, this.props); this.onPositionChanged(); @@ -133,8 +141,8 @@ export class Anchor extends GraphComponen return this.props.port.getPoint(); } - public toggleSelected() { - this.connectedState.setSelection(!this.state.selected); + public toggleSelected(): void { + this.connectedState?.setSelection(!this.state.selected); } /** @@ -153,15 +161,15 @@ export class Anchor extends GraphComponen } public override handleDragStart(context: DragContext): void { - this.connectedState.block.getViewComponent()?.handleDragStart(context); + this.connectedState?.block.getViewComponent()?.handleDragStart(context); } public override handleDrag(diff: DragDiff, context: DragContext): void { - this.connectedState.block.getViewComponent()?.handleDrag(diff, context); + this.connectedState?.block.getViewComponent()?.handleDrag(diff, context); } public override handleDragEnd(context: DragContext): void { - this.connectedState.block.getViewComponent()?.handleDragEnd(context); + this.connectedState?.block.getViewComponent()?.handleDragEnd(context); } protected isVisible() { @@ -169,9 +177,9 @@ export class Anchor extends GraphComponen return params ? this.context.camera.isRectVisible(...params) : true; } - protected unmount() { + protected unmount(): void { this.props.port.removeOwner(); - this.connectedState.unsetViewComponent(); + this.connectedState?.unsetViewComponent(); super.unmount(); } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 9f5026ca..ffa3c7ab 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -12,6 +12,7 @@ import { BlockState, IS_BLOCK_TYPE, TBlockId } from "../../../store/block/Block" import { selectBlockById } from "../../../store/block/selectors"; import { PortState } from "../../../store/connection/port/Port"; import { createAnchorPortId, createBlockPointPortId } from "../../../store/connection/port/utils"; +import { logDev } from "../../../utils/devLog"; import { isAllowDrag, isMetaKeyEvent } from "../../../utils/functions"; import { clamp } from "../../../utils/functions/clamp"; import { TMeasureTextOptions } from "../../../utils/functions/text"; @@ -104,7 +105,7 @@ export class Block; + public connectedState!: BlockState; private connectedStateUnsubscribers: (() => void)[] = []; @@ -112,13 +113,9 @@ export class Block