From 66ad42b3038ffb09ecfdbf5f58078201b2000803 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:18:48 +0900 Subject: [PATCH 1/4] perf(nodes): a connection drag's start and end measure every node's face handles once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share note 58. While a connection or an end grip is dragged every node mounts its face handles, and at the end it unmounts the unused ones — every node, in the same commit. Each node then measured its handles on its own: one React Flow `updateNodeInternals` call per node, each a store update run through every subscriber (the 63-node gacha banner Template: 848 handles). Counted on main: 61-62 measurement calls per start and as many per-node frames per release. Now the first node whose layout effect sees the drag state change measures every node in ONE call (`src/components/nodes/faceBatch.ts`), and the others skip: - at the start, at once: React applies the whole commit's DOM before any layout effect runs, so every node's new handles are in the DOM, nothing is painted, and React Flow's edges have not caught up — the moment each node measured itself in before; - at the end, on the next animation frame (only removals), as one node's removal is (the React Flow 008 order of #356 is kept); - a new drag cancels an end's measurement still waiting. Every other mount change (selection, focus, a document change) is measured per node as before. `docs/edge-routing.md` §ER17.1 states the rules. This is option 1 of the four arms measured (main / 1 / 2 / 1 + 2); the code is the measured copy with the batch moved into its own module. Production build, two interleaved rounds, medians of eight drags (input event to the second animation frame): gacha start 113-117 -> 73-74 ms and release 123-132 -> 78-83 ms at CPU x1; 795-817 -> 371-377 ms and 674-712 -> 280-301 ms at x4; Coffee not slower. Measurement calls per start / release 61-62 -> 1; the store updates in those windows 64-65 -> 4 and 75-81 -> 15. Tests: - `faceBatch.test.ts` (6): one commit's requests are one measurement of every node, at once for a start, on the next frame for an end; a new drag cancels the waiting end; the nodes are the canvas's when it runs. Two deliberate mutations (no merging; no cancel) fail it. - `e2e/connection-draw-faces.spec.ts` (5): one drag can land on an unused face of every node (React Flow picks each from its measured bounds — a node left out of the measurement fails it, checked by mutation); a drag released and started again in one task, and five back to back, then a drop on a face; a refused drawing then the next connection; Esc on an end grip drag then the next connection; no 008 in the console throughout. Checks: tsc, lint, full vitest (3 598 passed, 1 skipped), e2e shards; dev, fresh server: the new spec and the 9 related specs, 109 passed. --- docs/edge-routing.md | 27 +++ e2e/connection-draw-faces.spec.ts | 269 +++++++++++++++++++++++++ src/components/nodes/faceBatch.test.ts | 123 +++++++++++ src/components/nodes/faceBatch.ts | 69 +++++++ src/components/nodes/nodes.tsx | 13 +- 5 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 e2e/connection-draw-faces.spec.ts create mode 100644 src/components/nodes/faceBatch.test.ts create mode 100644 src/components/nodes/faceBatch.ts diff --git a/docs/edge-routing.md b/docs/edge-routing.md index 9731e798..08b962bd 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -1000,6 +1000,33 @@ on purpose: a face is placement, and taking the whole document away from an olde to protect it would cost more than it saves (decision D-A). The user-facing change note of the release that ships this says the same in one sentence. +### ER17.1 The face handles and when they are measured + +A node mounts the handle of a face only when it is needed: a face one of its +connections uses (always, hidden — React Flow draws no edge whose handle id is +missing), and every face while a connection or an end grip is dragged (every +node, so the drop can choose a face) or while the node is selected or +keyboard-focused (`src/components/nodes/nodes.tsx`, `facesOn`). A mount or +unmount without a size change is invisible to React Flow's ResizeObserver, so +the handles' bounds are measured again: + +- a change that ADDS a handle — at once, in the node's layout effect: before + paint and before React Flow's edges reach its store (a passive effect), so the + connection that needs the handle finds it (React Flow error 008 otherwise); +- a change that only REMOVES handles — on the next animation frame: a store + update in the layout effect would run React Flow's edge selectors of the + previous render, which still look for the removed handle; +- **the start and the end of a drag change every node in the same commit and + are measured ONCE for all nodes** (`src/components/nodes/faceBatch.ts`): the + first node whose layout effect sees the drag state change measures every + node in one `updateNodeInternals` call — at the start at once (the commit's + DOM is complete, nothing painted), at the end on the next frame (only + removals) — and a new drag cancels an end's measurement still waiting. + Measured one node at a time, each was one React Flow store update run + through every subscriber, and starting or ending a drag on the 63-node gacha + banner Template took about 1.5 to 2.5 times as long, on the production build + of one machine at CPU × 1 and × 4 (share note 58). + ## ER18. Moving a connection's end (line editing, first unit) An existing connection's source or target end can be moved to another side of the diff --git a/e2e/connection-draw-faces.spec.ts b/e2e/connection-draw-faces.spec.ts new file mode 100644 index 00000000..a5467a1a --- /dev/null +++ b/e2e/connection-draw-faces.spec.ts @@ -0,0 +1,269 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, openApp, routesSettled, test } from './support/loop' + +// The face handles while a connection is drawn (share note 58): when a drag +// begins every node mounts its faces and ONE measurement covers every node +// (src/components/nodes/faceBatch.ts); when it ends the unused faces go and one +// measurement on the next frame follows. On the small node-structure fixture: +// - one drag can land on an unused face of EVERY node: React Flow picks the +// closest handle from its measured bounds (`connectingto`), so a node left +// out of the measurement would offer none; +// - a drag released and started again in the same task (before the end's +// measurement has run), and five drags started and released back to back, +// leave the next connection made on the face it is dropped on, ending on +// that handle, its line drawn on every frame, the faces back afterwards; +// - a refused drawing (a repeat of a connection drawn the same way) makes +// nothing and the next connection is made; +// - Esc cancels a drag of an end grip (the canvas's one Esc-cancellable +// drag; drawing a new connection has no Esc cancel), the faces go back, +// and the next connection is made; +// - no React Flow 008 warning in any of it (printed by development builds). +// The cancel-and-restart checks dispatch mouse events in the page (React +// Flow's handle drag listens to `mousedown` on the handle, then `mousemove` / +// `mouseup` on the document), so the order inside one task is exact. + +const FIXTURE = readFileSync(new URL('./fixtures/node-structure-small.json', import.meta.url), 'utf8') +type L = { graph: { getState: () => any }; rf: { setViewport: (v: object, o?: object) => void } } +const AT_REST = { arrivals: [], waiting: [], triage: [], treated: ['out@bottom'], discharged: ['in@top'], rate: [], occupancy: [] } + +async function open(page: Page, warnings: string[]) { + page.on('console', (m) => { + if (/Couldn't create edge|error#008/.test(m.text())) warnings.push(m.text().slice(0, 160)) + }) + await openApp(page) + await page.setInputFiles('.toolbar__actions input[type=file]', { name: 'node-structure-small.json', mimeType: 'application/json', buffer: Buffer.from(FIXTURE, 'utf8') }) + await expect(page.locator('.react-flow__node')).toHaveCount(7) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1 }, { duration: 0 })) + await routesSettled(page) +} + +const mountedFaces = (page: Page) => + page.evaluate(() => { + const out: Record = {} + for (const n of document.querySelectorAll('.react-flow__node')) + out[(n as HTMLElement).dataset.id!] = [...n.querySelectorAll('.react-flow__handle.h--extra')].map((h) => (h as HTMLElement).dataset.handleid!).sort() + return out + }) +const edges = (page: Page) => + page.evaluate(() => { + const g = (window as unknown as { __loop: L }).__loop.graph.getState() + return { past: g.past.length as number, list: g.edges.map((e: any) => ({ id: e.id as string, source: e.source as string, target: e.target as string, ports: e.data?.ports ?? null })) } + }) +const face = (page: Page, node: string, handle: string) => page.locator(`.react-flow__node[data-id="${node}"] .react-flow__handle[data-handleid="${handle}"]`) +const centreOf = async (page: Page, node: string, handle: string) => { + const b = (await face(page, node, handle).boundingBox())! + return { x: b.x + b.width / 2, y: b.y + b.height / 2 } +} + +/** an empty spot of the pane, away from every node and line */ +async function emptySpot(page: Page) { + const b = (await page.locator('.react-flow__pane').boundingBox())! + return { x: b.x + b.width - 380, y: b.y + b.height - 40 } +} + +/** after a drop: nothing selected, nothing focused, so each node shows the faces its connections use */ +async function settle(page: Page) { + const e = await emptySpot(page) + await page.mouse.click(e.x, e.y) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} + +/** the connection's end is on the handle's centre, within 2 px */ +const endToHandle = (page: Page, edgeId: string, node: string, handle: string) => + page.evaluate( + ({ edgeId, node, handle }) => { + const p = document.querySelector(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`) as SVGPathElement | null + const h = document.querySelector(`.react-flow__node[data-id="${node}"] .react-flow__handle[data-handleid="${handle}"]`) + if (!p || !h) return null + const q = p.getPointAtLength(p.getTotalLength()) + const m = p.getScreenCTM()! + const r = h.getBoundingClientRect() + return Math.hypot(m.a * q.x + m.c * q.y + m.e - (r.left + r.right) / 2, m.b * q.x + m.d * q.y + m.f - (r.top + r.bottom) / 2) + }, + { edgeId, node, handle }, + ) + +/** the connection's path is in the DOM with a `d` on every animation frame for 1 s; the frames without it */ +const framesWithoutPath = (page: Page, edgeId: string) => + page.evaluate( + (edgeId) => + new Promise((res) => { + let missing = 0 + const t0 = performance.now() + const tick = () => { + const p = document.querySelector(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`) + if (!p || !p.getAttribute('d')) missing++ + if (performance.now() - t0 < 1000) requestAnimationFrame(tick) + else res(missing) + } + requestAnimationFrame(tick) + }), + edgeId, + ) + +/** draw from `from`'s handle onto `to`'s face with the real pointer */ +async function draw(page: Page, from: [string, string], to: [string, string]) { + const a = await centreOf(page, ...from) + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + await expect(face(page, ...to)).toHaveCount(1) + const b = await centreOf(page, ...to) + await page.mouse.move(b.x, b.y, { steps: 12 }) + await page.mouse.up() +} + +/** the last connection is `from` → `to` on the target face, drawn on its handle on every frame */ +async function expectMade(page: Page, n0: number, source: string, target: string, handle: string, side: string) { + await expect.poll(async () => (await edges(page)).list.length).toBe(n0 + 1) + const made = (await edges(page)).list.at(-1)! + expect(made).toMatchObject({ source, target, ports: { target: side } }) + await expect.poll(() => endToHandle(page, made.id, target, handle)).toBeLessThan(2) + expect(await framesWithoutPath(page, made.id), 'animation frames without the new line').toBe(0) + return made +} + +/** in-page mouse events on a handle and the document, in React Flow's order */ +const dispatch = (page: Page, steps: ([string, string, string] | ['move' | 'up', number, number])[]) => + page.evaluate((steps) => { + let rafRan = false + requestAnimationFrame(() => (rafRan = true)) + const fire = (t: EventTarget, type: string, x: number, y: number, buttons: number) => + t.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons, view: window })) + let last = { x: 0, y: 0 } + for (const s of steps) { + if (s[0] === 'down') { + const h = document.querySelector(`.react-flow__node[data-id="${s[1]}"] .react-flow__handle[data-handleid="${s[2]}"]`)! + const r = h.getBoundingClientRect() + last = { x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 } + fire(h, 'mousedown', last.x, last.y, 1) + } else if (s[0] === 'move') { + last = { x: last.x + (s[1] as number), y: last.y + (s[2] as number) } + fire(document, 'mousemove', last.x, last.y, 1) + } else { + fire(document.elementFromPoint(last.x, last.y) ?? document, 'mouseup', last.x, last.y, 0) + } + } + return { rafRan } + }, steps) + +test.describe('drawing a connection: the face handles of every node (share note 58)', () => { + test('one drag can land on an unused face of every node; the faces go when it ends', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const a = await centreOf(page, 'arrivals', 'out') + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + // every node that takes an input offers its unused faces, and React Flow + // finds each (the Register has no resource input: its state input face) + for (const [node, handle] of [['waiting', 'in@top'], ['triage', 'in@bottom'], ['treated', 'in@top'], ['discharged', 'in@bottom'], ['occupancy', 'state-target@left']] as const) { + await expect(face(page, node, handle)).toHaveCount(1) + const b = await centreOf(page, node, handle) + await page.mouse.move(b.x, b.y, { steps: 6 }) + await expect(face(page, node, handle), `${node} ${handle}: measured, so React Flow picks it`).toHaveClass(/\bconnectingto\b/) + } + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + await page.mouse.move(e.x, e.y, { steps: 6 }) + await page.mouse.up() + await settle(page) + expect((await edges(page)).list.length).toBe(n0) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + expect(warnings).toEqual([]) + }) + + test('released and started again in one task, then dropped on a face: made on that face', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + const out = await centreOf(page, 'arrivals', 'out') + const { rafRan } = await dispatch(page, [ + ['down', 'arrivals', 'out'], + ['move', 30, 30], + ['move', e.x - out.x - 30, e.y - out.y - 30], + ['up', 0, 0], + ['down', 'arrivals', 'out'], + ['move', 30, 30], + ]) + expect(rafRan, 'the new drag started before the next animation frame').toBe(false) + await expect.poll(async () => (await mountedFaces(page)).discharged).toContain('in@bottom') + // the rest of the drag with the real pointer is not possible (its button is + // not down), so it is finished in the page too, onto discharged's bottom face + const t = await centreOf(page, 'discharged', 'in@bottom') + await page.evaluate(({ x, y }) => { + const fire = (tg: EventTarget, type: string, b: number) => tg.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons: b, view: window })) + fire(document, 'mousemove', 1) + fire(document.elementFromPoint(x, y) ?? document, 'mouseup', 0) + }, t) + await expectMade(page, n0, 'arrivals', 'discharged', 'in@bottom', 'bottom') + await settle(page) + await expect.poll(() => mountedFaces(page)).toEqual({ ...AT_REST, discharged: ['in@bottom', 'in@top'] }) + expect(warnings).toEqual([]) + }) + + test('five drags started and released back to back, then a connection drawn onto a face', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + for (const [node, handle] of [['arrivals', 'out'], ['waiting', 'out'], ['triage', 'out'], ['arrivals', 'out'], ['waiting', 'out']] as const) { + const c = await centreOf(page, node, handle) + await dispatch(page, [['down', node, handle], ['move', 25, 25], ['move', e.x - c.x - 25, e.y - c.y - 25], ['up', 0, 0]]) + } + expect((await edges(page)).list.length, 'the cancelled drags made nothing').toBe(n0) + await draw(page, ['waiting', 'out'], ['treated', 'in@top']) + await expectMade(page, n0, 'waiting', 'treated', 'in@top', 'top') + await settle(page) + await expect.poll(() => mountedFaces(page)).toEqual({ ...AT_REST, treated: ['in@top', 'out@bottom'] }) + expect(warnings).toEqual([]) + }) + + test('a refused drawing makes nothing, the faces go back, and the next connection is made', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const before = await edges(page) + // arrivals → waiting on the default faces repeats e_arrive: refused + await draw(page, ['arrivals', 'out'], ['waiting', 'in']) + await settle(page) + const after = await edges(page) + expect(after.list).toEqual(before.list) + expect(after.past, 'nothing recorded').toBe(before.past) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + await draw(page, ['arrivals', 'out'], ['discharged', 'in@bottom']) + await expectMade(page, before.list.length, 'arrivals', 'discharged', 'in@bottom', 'bottom') + expect(warnings).toEqual([]) + }) + + test('Esc cancels an end grip drag, the faces go back, and the next connection is made', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const before = await edges(page) + // select e_leave on its own line, so its end grips show + const p = await page.locator('.react-flow__edge[data-id="e_leave"] path.react-flow__edge-path').evaluate((el) => { + const s = el as SVGPathElement + const q = s.getPointAtLength(s.getTotalLength() * 0.3) + const m = s.getScreenCTM()! + return { x: q.x * m.a + q.y * m.c + m.e, y: q.x * m.b + q.y * m.d + m.f } + }) + await page.mouse.click(p.x, p.y) + const grip = page.locator('.end-grip[data-end="target"]') + await expect(grip).toHaveCount(1) + const g = (await grip.boundingBox())! + await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2) + await page.mouse.down() + await page.mouse.move(g.x + g.width / 2 + 20, g.y + g.height / 2 + 20, { steps: 5 }) + await expect.poll(async () => (await mountedFaces(page)).waiting).toContain('in@top') + await page.keyboard.press('Escape') + await expect(page.locator('.react-flow__connection')).toHaveCount(0) + await page.mouse.up() + await settle(page) + expect((await edges(page)).list).toEqual(before.list) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + await draw(page, ['arrivals', 'out'], ['discharged', 'in@bottom']) + await expectMade(page, before.list.length, 'arrivals', 'discharged', 'in@bottom', 'bottom') + expect(warnings).toEqual([]) + }) +}) diff --git a/src/components/nodes/faceBatch.test.ts b/src/components/nodes/faceBatch.test.ts new file mode 100644 index 00000000..a967e7b3 --- /dev/null +++ b/src/components/nodes/faceBatch.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetFaceBatchForTests, measureFacesForDrag } from './faceBatch' + +// ./faceBatch (share note 58): when a connection drag begins or ends, every +// node's layout effect asks for its faces to be measured; the requests of one +// commit become ONE React Flow `updateNodeInternals` call holding every node +// on the canvas — at once for a start, on the next animation frame for an end +// — and a start cancels an end's measurement still waiting. What is pinned is +// the measurement calls and the nodes in them, not React Flow's own store +// updates (it makes others at a start and a release). + +type Update = Map + +/** a canvas with node elements; `undefined` is an element without a node id */ +function canvas(ids: (string | undefined)[]) { + const calls: { ids: string[]; force: boolean[]; params: unknown }[] = [] + let els = ids.map((id) => ({ dataset: id === undefined ? {} : { id } })) + const store = { + getState: () => ({ + domNode: { + querySelectorAll: (sel: string) => { + expect(sel).toBe('.react-flow__node') + return els + }, + }, + updateNodeInternals: (updates: Update, params: unknown) => { + calls.push({ ids: [...updates.keys()], force: [...updates.values()].map((u) => u.force), params }) + }, + }), + } + return { store: store as never, calls, setNodes: (next: string[]) => (els = next.map((id) => ({ dataset: { id } }))) } +} + +/** animation frames run by hand */ +const frames = new Map() +let nextFrame = 1 +const runFrames = () => { + const due = [...frames.entries()] + frames.clear() + for (const [, cb] of due) cb(0) +} + +beforeEach(() => { + frames.clear() + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, cb) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + frames.delete(id) + }) + __resetFaceBatchForTests() +}) +afterEach(() => { + __resetFaceBatchForTests() + vi.unstubAllGlobals() +}) + +describe('the face measurement of a drag start / end', () => { + it('a start: the requests of every node in the commit are one measurement, at once, of every node', () => { + const c = canvas(['a', 'b', 'c', 'd']) + // four nodes' layout effects in one commit + for (let i = 0; i < 4; i++) measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(1) + expect(c.calls[0]!.ids).toEqual(['a', 'b', 'c', 'd']) + expect(c.calls[0]!.force).toEqual([true, true, true, true]) + expect(c.calls[0]!.params).toEqual({ triggerFitView: false }) + expect(frames.size, 'nothing deferred at a start').toBe(0) + }) + + it('an element without a node id is not measured', () => { + const c = canvas(['a', undefined, 'b']) + measureFacesForDrag(c.store, true) + expect(c.calls[0]!.ids).toEqual(['a', 'b']) + }) + + it('an end: nothing at once; one measurement of every node on the next animation frame', () => { + const c = canvas(['a', 'b', 'c']) + measureFacesForDrag(c.store, true) + for (let i = 0; i < 3; i++) measureFacesForDrag(c.store, false) + expect(c.calls, 'the end measures nothing at once').toHaveLength(1) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + expect(c.calls[1]!.ids).toEqual(['a', 'b', 'c']) + runFrames() + expect(c.calls, 'one frame, one measurement').toHaveLength(2) + }) + + it('a new drag before the end\'s frame: that frame never measures, the start measures at once', () => { + const c = canvas(['a', 'b']) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, false) + expect(frames.size).toBe(1) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, true) + expect(frames.size, 'the end\'s waiting frame is cancelled').toBe(0) + expect(c.calls).toHaveLength(2) + runFrames() + expect(c.calls, 'no late measurement lands on the new drag').toHaveLength(2) + // that drag's own end is measured as usual + measureFacesForDrag(c.store, false) + runFrames() + expect(c.calls).toHaveLength(3) + }) + + it('the nodes measured are the canvas\'s when the measurement runs', () => { + const c = canvas(['a', 'b']) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, false) + c.setNodes(['a', 'b', 'n']) + runFrames() + expect(c.calls[1]!.ids).toEqual(['a', 'b', 'n']) + }) + + it('an end with no drag begun measures nothing', () => { + const c = canvas(['a']) + measureFacesForDrag(c.store, false) + runFrames() + expect(c.calls).toHaveLength(0) + }) +}) diff --git a/src/components/nodes/faceBatch.ts b/src/components/nodes/faceBatch.ts new file mode 100644 index 00000000..58977d9a --- /dev/null +++ b/src/components/nodes/faceBatch.ts @@ -0,0 +1,69 @@ +import type { useStoreApi } from '@xyflow/react' + +/** + * The face handles' measurement when a connection drag begins or ends. + * + * Every node mounts its face handles while a connection is dragged (so the + * drop can choose a face, `nodes.tsx` `facesOn`) and unmounts the unused ones + * when it ends — every node, in the same commit. A mount or unmount without a + * size change is invisible to React Flow's ResizeObserver, so the handles' + * bounds must be measured again; done one node at a time, that was one React + * Flow store update per node, each run through every store subscriber, and it + * made the start and the end of a drag on a large diagram slow (share note 58: + * the 63-node gacha banner Template). + * + * So the first node whose layout effect sees the drag state change measures + * EVERY node in one `updateNodeInternals` call, and the others skip: + * - at the start, at once: React applies the whole commit's DOM changes before + * any layout effect runs, so every node's new handles are in the DOM, + * nothing is painted yet, and React Flow's edges (which reach its store in a + * passive effect) have not caught up — the moment each node measured itself + * in before; + * - at the end, on the next animation frame: the end only removes handles + * (every face was mounted during the drag), and a removal is measured late, + * as a node's own removal is (a store update in this layout effect would run + * React Flow's edge selectors of the previous render, which still look for + * the removed handle — React Flow error 008, share note 53); + * - a start cancels an end's measurement that is still waiting, so it never + * lands on the next drag. + * Every other change of a node's mounted faces (a selection, a keyboard focus, + * a document change) is measured by that node alone, as before. + * + * One canvas exists, so the state is the module's. + */ +type RfStore = Pick, 'getState'> + +const state = { on: false, frame: 0 } + +/** measure every node's handles in ONE React Flow update */ +function measureAll(rfStore: RfStore): void { + const { domNode, updateNodeInternals } = rfStore.getState() + const updates = new Map() + domNode?.querySelectorAll('.react-flow__node').forEach((el) => { + const id = el.dataset.id + if (id) updates.set(id, { id, nodeElement: el, force: true }) + }) + if (updates.size) updateNodeInternals(updates, { triggerFitView: false }) +} + +/** a node's layout effect saw the drag state become `connecting` in this commit */ +export function measureFacesForDrag(rfStore: RfStore, connecting: boolean): void { + // the first node of the commit acts; the others find it done + if (connecting === state.on) return + state.on = connecting + cancelAnimationFrame(state.frame) + state.frame = 0 + if (connecting) measureAll(rfStore) + else + state.frame = requestAnimationFrame(() => { + state.frame = 0 + measureAll(rfStore) + }) +} + +/** tests only: back to no drag, nothing waiting */ +export function __resetFaceBatchForTests(): void { + if (state.frame) cancelAnimationFrame(state.frame) + state.on = false + state.frame = 0 +} diff --git a/src/components/nodes/nodes.tsx b/src/components/nodes/nodes.tsx index 43c6b485..79997b1e 100644 --- a/src/components/nodes/nodes.tsx +++ b/src/components/nodes/nodes.tsx @@ -36,6 +36,7 @@ import { InsideMask, OutsideMask } from './RingMasks' import { fitRows, type MeasuredRow, type RowFit, rowFitMeasured } from './rowFit' import { type Box, convMarkSpot, type Spot } from './convMark' import { useConversionMark, usePoolPulseKey } from './nodeCues' +import { measureFacesForDrag } from './faceBatch' import type { ConverterData, DrainData, @@ -752,14 +753,24 @@ function NodeFrame({ // store update in this layout effect would run React Flow's edge selectors of // the previous render (useSyncExternalStore swaps them in a passive effect), // which still look for the removed handle. + // When a connection drag begins or ends, every node's faces change in the + // same commit: the first node to get here measures them all at once, in the + // same order (./faceBatch, share note 58). const rfStore = useStoreApi() const updateNodeInternals = useUpdateNodeInternals() const mountedFacesKey = outside ? (facesOn ? '*' : usedHandlesKey) : '' const facesKeyRef = useRef(mountedFacesKey) + const connectingRef = useRef(connecting) useLayoutEffect(() => { const prev = facesKeyRef.current + const wasConnecting = connectingRef.current + connectingRef.current = connecting if (prev === mountedFacesKey) return facesKeyRef.current = mountedFacesKey + if (wasConnecting !== connecting) { + measureFacesForDrag(rfStore, connecting) + return + } if (addsHandle(prev, mountedFacesKey)) { const { domNode, updateNodeInternals: measureNow } = rfStore.getState() const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${CSS.escape(nodeId)}"]`) @@ -767,7 +778,7 @@ function NodeFrame({ } else { updateNodeInternals(nodeId) } - }, [mountedFacesKey, nodeId, rfStore, updateNodeInternals]) + }, [mountedFacesKey, connecting, nodeId, rfStore, updateNodeInternals]) // §VL3 stacking — every state is its own layer, so a Register that is // selected AND keyboard-focused AND invalid shows all three cues at once: // the outer --warning invalid ring, the solid selection ring, the inset From c287eb3e56b2d58bff1baf1cf5c20c5374331a39 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:29:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore(release):=200.28.4=20=E2=80=94=20star?= =?UTF-8?q?ting=20and=20finishing=20a=20connection=20responds=20faster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json / package-lock.json 0.28.4; `.changes/draw-start-batch.json` (user-facing, `release:0.28.4`, Lumi's classification 2026-10-17), date 2026-10-17, to be checked on the merge day. - What's new 0.28.4, three lines in 18 languages (Daisy's draft on Lumi's direction "the response when you start and finish drawing a connection is improved", for review; the measured reductions stay in the CHANGELOG and the technical record): starting to draw responds faster, especially in large diagrams; letting go too, made or dropped on the empty canvas, and dragging an end grip; nothing else changes, every side of every node is still offered. The release-note validator asks for 3 to 5 lines, so the one-sentence direction is three lines. Grip / canvas / side words follow each locale's 0.27.0 line and UI. es-ES = es-419 and pt-PT = pt-BR, written neutrally; French avoids the punctuation its spacing rule governs. Locale count pins follow (catalog 1148, runtime 1370); ru adds `создаётся` to its ё list; the e2e release list pins 0.28.4. - CHANGELOG v0.28.4 (what was slow, now, how much on one machine); README Latest v0.28.4 (v0.28.3 kept as a section, v0.28.0 moved into the CHANGELOG list). The translation contract tests pass: evidence of the catalogs' structure and terms, not a review of how the lines read; no native-speaker review. Checks: tsc, lint, full vitest (3 598 passed, 1 skipped), change declaration, i18n, i18n surface, control bytes, isolate / ICU arguments, locale lists, functional glyphs, e2e shards, snapshot policy, form / content direction, no pseudo-locales. --- .changes/draw-start-batch.json | 1 + CHANGELOG.md | 10 ++++++++++ README.md | 30 ++++++++++++------------------ e2e/whats-new.spec.ts | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/i18n/itCopy.test.ts | 6 +++--- src/i18n/locales/ar/ui.ts | 3 +++ src/i18n/locales/de/ui.ts | 3 +++ src/i18n/locales/en/ui.ts | 3 +++ src/i18n/locales/es-419/ui.ts | 3 +++ src/i18n/locales/es-ES/ui.ts | 3 +++ src/i18n/locales/fr/ui.ts | 3 +++ src/i18n/locales/it/ui.ts | 3 +++ src/i18n/locales/ja/ui.ts | 3 +++ src/i18n/locales/ko/ui.ts | 3 +++ src/i18n/locales/nl/ui.ts | 3 +++ src/i18n/locales/pt-BR/ui.ts | 3 +++ src/i18n/locales/pt-PT/ui.ts | 3 +++ src/i18n/locales/ru/ui.ts | 3 +++ src/i18n/locales/th/ui.ts | 3 +++ src/i18n/locales/tr/ui.ts | 3 +++ src/i18n/locales/vi/ui.ts | 3 +++ src/i18n/locales/zh-Hans/ui.ts | 3 +++ src/i18n/locales/zh-Hant/ui.ts | 3 +++ src/i18n/nlCopy.test.ts | 6 +++--- src/i18n/ptPtCopy.test.ts | 2 +- src/i18n/ruCopy.test.ts | 3 ++- src/i18n/thCopy.test.ts | 4 ++-- src/i18n/trCopy.test.ts | 2 +- src/i18n/viCopy.test.ts | 4 ++-- src/releaseNotes/releaseNotes.ts | 9 +++++++++ 32 files changed, 104 insertions(+), 35 deletions(-) create mode 100644 .changes/draw-start-batch.json diff --git a/.changes/draw-start-batch.json b/.changes/draw-start-batch.json new file mode 100644 index 00000000..9d6b0e9a --- /dev/null +++ b/.changes/draw-start-batch.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.28.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d9382c..0ee44a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.28.4 — 2026-10-17 + +Starting and finishing a connection drag responds faster ([`docs/edge-routing.md`](docs/edge-routing.md) §ER17.1). + +- **What was slow.** While a connection or an end grip is dragged, every node shows all of its sides so the drop can choose one, and when the drag ends the unused ones go. Each node then measured its handles again on its own — one update of the canvas's state per node, each passed to everything listening to it — so the start and the end of a drag grew slow on large diagrams. +- **Now.** One measurement covers every node: at the start at once, before anything is drawn; at the end on the next frame, as before. A new drag started right after one ends is measured on its own. What is offered while drawing, and where a connection lands, are unchanged. +- **How much.** On one machine's production build, the 63-node 3-zone gacha banner comparison started a drag in about 70–80 ms instead of 100–120 ms, and in about 370–460 ms instead of 800–850 ms with the processor slowed four times; releasing improved about as much; Coffee roastery operations flow did not get slower. The full measurement is kept with the work, not in the app. + +**Notes.** Three What's new lines in 18 languages: the translation contract tests pass, and none had native-speaker review. + ## v0.28.3 — 2026-10-15 Undo and Redo leave nothing selected, on the canvas as in the Inspector ([`docs/large-graph-readability.md`](docs/large-graph-readability.md) LGR-D17); a module insert restores its own selection ([`docs/module-system.md`](docs/module-system.md) §MS3.5). diff --git a/README.md b/README.md index eef22883..89f21e95 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,16 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.28.3 +## Latest — v0.28.4 + +Starting and finishing a connection responds faster. + +- **Starting to draw**: responds faster, especially in large diagrams +- **Letting go**: faster too, whether the connection is made or dropped on the empty canvas, + and so is dragging a connection's end grip +- **Nothing else changes**: while you draw, every side of every node is still offered + +## v0.28.3 Undo and Redo leave nothing selected. @@ -176,23 +185,8 @@ A connection's label keeps off the bend points you placed on it, where its line - **Early MMO example**: no longer listed under Templates; it stays as developer test material for large diagrams, and a document you already made from it still opens and works as before -## v0.28.0 - -A connection can be turned around without drawing it again. - -- **Reverse a connection**: select it and press Reverse beside Ends in the Inspector; its - amount, condition, colour and line shape stay as they were and are read in the new - direction, the bends you placed keep their places, and one undo step takes it back -- **What may look different**: the automatic route, the line next to each end and the - label's place may be drawn differently after a reversal -- **Refused reversals say why**: a reversal that would end a connection at a node with no - input, start it at a node with no output, repeat a connection drawn the same way, or leave a - state connection with no effect is not applied, and the reason is shown - -On a phone the diagram stays read-only. Curves you shape yourself and moving a connection's -label are not in this release. - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.27.0 (a +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.28.0 (a +connection turned around without drawing it again), v0.27.0 (a connection's end moves without drawing the connection again), v0.26.2 (playback in the chosen colours, and the moving `+N` badges kept off the nodes), v0.26.0 (the compact node with its name outside, connections from any side, notices under the toolbar), diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 4375186a..96306d22 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -238,7 +238,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.28.3', '0.28.2', '0.28.1', '0.28.0', '0.27.0', '0.26.2', '0.26.0', '0.25.0', '0.24.0', '0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.28.4', '0.28.3', '0.28.2', '0.28.1', '0.28.0', '0.27.0', '0.26.2', '0.26.0', '0.25.0', '0.24.0', '0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) await page.keyboard.press('Escape') await expect(panel).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index 3334ed91..02754647 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.28.3", + "version": "0.28.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.28.3", + "version": "0.28.4", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index bf87f68b..c0ade856 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.28.3", + "version": "0.28.4", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index be0c6c3c..0a91d75c 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -208,15 +208,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('it copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) expect(Object.keys(IT).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1145, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1367) + expect(per).toEqual({ catalog: 1148, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1370) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index fb52947b..4f81ccd5 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -656,6 +656,9 @@ const ui = { 'whatsNew.v0283.hidden': 'بعد التراجع أو الإعادة لم يعد الرابط أو العقدة يبقى مُبرَزًا بينما المفتّش فارغ. وإلى أن تنقر عليه لتحديده، لا يحذفه المفتاح Delete، ولا تحرّك مفاتيح الأسهم العقدة.', 'whatsNew.v0283.history': 'لم يعد التراجع والإعادة يعيدان تحديدًا من خطوة سابقة.', 'whatsNew.v0283.module': 'الاستثناء الوحيد هو إدراج وحدة: يحدد التراجع من جديد ما كان محددًا قبل الإدراج، وتحدد الإعادة العُقد المُدرجة.', + 'whatsNew.v0284.start': 'أصبح بدء رسم الرابط يستجيب أسرع، ولا سيما في المخططات الكبيرة.', + 'whatsNew.v0284.release': 'وأصبح الإفلات أسرع أيضًا، سواء أُنشئ الرابط أم أُفلت على لوحة فارغة، وكذلك سحب المقبض في طرف الرابط.', + 'whatsNew.v0284.same': 'لا شيء آخر يتغيّر: أثناء الرسم تظل كل جوانب كل العقد معروضة.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 6023e461..fe5deb41 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -691,6 +691,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Nach Rückgängig oder Wiederherstellen bleibt keine Verbindung und kein Knoten mehr hervorgehoben, während die Eigenschaften leer sind. Bis Sie das Element anklicken und damit auswählen, entfernt Entf es nicht, und die Pfeiltasten bewegen keinen Knoten.', 'whatsNew.v0283.history': 'Rückgängig und Wiederherstellen holen keine Auswahl aus einem früheren Schritt mehr zurück.', 'whatsNew.v0283.module': 'Einzige Ausnahme ist das Einfügen eines Moduls: Rückgängig wählt wieder aus, was vor dem Einfügen ausgewählt war, und Wiederherstellen wählt die eingefügten Knoten aus.', + 'whatsNew.v0284.start': 'Wenn Sie beginnen, eine Verbindung zu zeichnen, reagiert die Ansicht jetzt schneller, besonders in großen Diagrammen.', + 'whatsNew.v0284.release': 'Auch das Loslassen reagiert schneller, ob die Verbindung entsteht oder Sie auf der leeren Arbeitsfläche loslassen; ebenso das Ziehen am Griff eines Verbindungsendes.', + 'whatsNew.v0284.same': 'Sonst ändert sich nichts: Während Sie zeichnen, werden weiterhin alle Seiten aller Knoten angeboten.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 3513118f..225e4d41 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -601,6 +601,9 @@ const ui = { 'whatsNew.v0283.hidden': 'After Undo or Redo, a connection or node no longer stays highlighted while the Inspector is empty. Delete won’t remove it, and the arrow keys won’t move a node until you select it.', 'whatsNew.v0283.history': 'Undo and Redo no longer bring back a selection from an earlier step.', 'whatsNew.v0283.module': 'Inserting a module is the one exception: Undo selects again what was selected before the insert, and Redo selects the inserted nodes.', + 'whatsNew.v0284.start': 'Starting to draw a connection now responds faster, especially in large diagrams.', + 'whatsNew.v0284.release': 'Letting go responds faster too, whether the connection is made or you drop it on the empty canvas, and so does dragging a connection’s end grip.', + 'whatsNew.v0284.same': 'Nothing else changes: while you draw, every side of every node is still offered.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index a7a5c7fc..c749fc8d 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -615,6 +615,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión ahora responde más rápido, sobre todo en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar también responde más rápido, ya sea que se cree la conexión o que la suelte en el lienzo vacío, y lo mismo al arrastrar el asa de un extremo.', + 'whatsNew.v0284.same': 'Nada más cambia: mientras dibuja, se siguen ofreciendo todos los lados de todos los nodos.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index fe55d4d6..7bfbaa9d 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -629,6 +629,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión ahora responde más rápido, sobre todo en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar también responde más rápido, ya sea que se cree la conexión o que la suelte en el lienzo vacío, y lo mismo al arrastrar el asa de un extremo.', + 'whatsNew.v0284.same': 'Nada más cambia: mientras dibuja, se siguen ofreciendo todos los lados de todos los nodos.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index d86cc2a8..37277490 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -685,6 +685,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Après Annuler ou Rétablir, une connexion ou un nœud ne reste plus en surbrillance alors que les Propriétés n’affichent rien. Tant que vous ne cliquez pas dessus pour le sélectionner, Suppr ne le supprime pas et les flèches ne déplacent aucun nœud.', 'whatsNew.v0283.history': 'Annuler et Rétablir ne ramènent plus une sélection d’une étape antérieure.', 'whatsNew.v0283.module': 'L’insertion d’un module est la seule exception. Annuler sélectionne de nouveau ce qui l’était avant l’insertion, et Rétablir sélectionne les nœuds insérés.', + 'whatsNew.v0284.start': 'Commencer à tracer une connexion réagit désormais plus vite, surtout dans les grands diagrammes.', + 'whatsNew.v0284.release': 'Le relâchement réagit aussi plus vite, que la connexion soit créée ou relâchée sur le canevas vide, de même que le glissement de la poignée d’une extrémité.', + 'whatsNew.v0284.same': 'Rien d’autre ne change. Pendant le tracé, tous les côtés de tous les nœuds restent proposés.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 062617d4..1e9ec461 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -631,6 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Dopo Annulla o Ripeti, un collegamento o un nodo non resta più evidenziato mentre l’Ispettore non mostra nulla. Finché non ci fai clic per selezionarlo, Canc non lo rimuove e i tasti freccia non spostano nessun nodo.', 'whatsNew.v0283.history': 'Annulla e Ripeti non riportano più una selezione di un passaggio precedente.', 'whatsNew.v0283.module': 'L’unica eccezione è l’inserimento di un modulo: Annulla seleziona di nuovo ciò che era selezionato prima dell’inserimento, e Ripeti seleziona i nodi inseriti.', + 'whatsNew.v0284.start': 'Iniziare a disegnare un collegamento ora risponde più rapidamente, soprattutto nei diagrammi grandi.', + 'whatsNew.v0284.release': 'Anche il rilascio è più rapido, sia che il collegamento venga creato sia che lo lasci sull’area di disegno vuota, e così trascinare la maniglia di un estremo.', + 'whatsNew.v0284.same': 'Nient’altro cambia: mentre disegni, tutti i lati di tutti i nodi restano disponibili.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index d93a47e5..30f254f7 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -636,6 +636,9 @@ const ui = { 'whatsNew.v0283.hidden': '元に戻す・やり直すの後、インスペクターが空なのに接続やノードが強調されたまま残ることはなくなりました。クリックして選択するまで、Delete で削除されることも、ノードが矢印キーで動くこともありません。', 'whatsNew.v0283.history': '元に戻す・やり直すで、以前の手順の選択が戻ってくることはなくなりました。', 'whatsNew.v0283.module': 'モジュールの挿入だけは例外です。元に戻すと挿入前に選択していたものが再び選択され、やり直すと挿入したノードが選択されます。', + 'whatsNew.v0284.start': '接続を描き始めるときの反応が速くなりました。大きなダイアグラムほど効果があります。', + 'whatsNew.v0284.release': '離したときの反応も速くなりました。接続ができたときも空のキャンバスで離したときも同じで、接続の端のつまみをドラッグするときも同様です。', + 'whatsNew.v0284.same': 'それ以外は変わりません。描いている間は、これまでどおりすべてのノードのすべての辺が表示されます。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 0a5607d6..b37f5763 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -604,6 +604,9 @@ const ui = { 'whatsNew.v0283.hidden': '실행 취소나 다시 실행 뒤, 인스펙터는 비어 있는데 연결이나 노드가 강조된 채 남는 일이 없습니다. 클릭해서 선택하기 전에는 Delete로 지워지지 않고, 노드가 화살표 키로 움직이지도 않습니다.', 'whatsNew.v0283.history': '실행 취소와 다시 실행이 이전 단계의 선택을 되살리지 않습니다.', 'whatsNew.v0283.module': '모듈 삽입만 예외입니다. 실행 취소하면 삽입 전에 선택했던 것이 다시 선택되고, 다시 실행하면 삽입한 노드가 선택됩니다.', + 'whatsNew.v0284.start': '연결을 그리기 시작할 때의 반응이 빨라졌습니다. 큰 다이어그램일수록 차이가 큽니다.', + 'whatsNew.v0284.release': '연결을 놓을 때도 빨라졌습니다. 연결이 만들어질 때와 빈 캔버스에 놓을 때 모두 해당하며, 연결 끝의 잡는 점을 끌 때도 같습니다.', + 'whatsNew.v0284.same': '그 밖에 달라진 것은 없습니다. 그리는 동안 모든 노드의 모든 면이 그대로 표시됩니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 8b51105b..9d4cf381 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -612,6 +612,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Na Ongedaan maken of Opnieuw uitvoeren blijft een verbinding of knooppunt niet meer gemarkeerd terwijl de Inspector niets toont. Tot je erop klikt om het te selecteren, verwijdert Delete het niet en verplaatsen de pijltoetsen geen knooppunt.', 'whatsNew.v0283.history': 'Ongedaan maken en Opnieuw uitvoeren halen geen selectie uit een eerdere stap meer terug.', 'whatsNew.v0283.module': 'Een module invoegen is de enige uitzondering: Ongedaan maken herstelt de selectie van vóór het invoegen, en Opnieuw uitvoeren selecteert de ingevoegde knooppunten.', + 'whatsNew.v0284.start': 'Beginnen met het tekenen van een verbinding reageert nu sneller, vooral in grote diagrammen.', + 'whatsNew.v0284.release': 'Ook loslaten reageert sneller, of de verbinding nu ontstaat of je haar op het lege tekengebied loslaat, en net zo het slepen aan de greep van een uiteinde.', + 'whatsNew.v0284.same': 'Verder verandert er niets: tijdens het tekenen worden nog steeds alle zijden van alle knooppunten aangeboden.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index a60ead04..5b657e86 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -617,6 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação passa a responder mais rapidamente, sobretudo em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar também responde mais rapidamente, quer a ligação seja criada, quer seja solta na tela vazia, e o mesmo acontece ao arrastar a alça de uma extremidade.', + 'whatsNew.v0284.same': 'Nada mais muda: enquanto desenha, todos os lados de todos os nós continuam a ser oferecidos.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index 09c90c6a..d9fab158 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -631,6 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação passa a responder mais rapidamente, sobretudo em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar também responde mais rapidamente, quer a ligação seja criada, quer seja solta na tela vazia, e o mesmo acontece ao arrastar a alça de uma extremidade.', + 'whatsNew.v0284.same': 'Nada mais muda: enquanto desenha, todos os lados de todos os nós continuam a ser oferecidos.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index 25e4dfb2..2a4e0196 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -684,6 +684,9 @@ const ui = { 'whatsNew.v0283.hidden': 'После отмены или повтора связь или узел больше не остаётся подсвеченным, пока инспектор пуст. Пока вы не выделите элемент щелчком, Delete его не удалит, а стрелки не сдвинут узел.', 'whatsNew.v0283.history': 'Отмена и повтор больше не возвращают выделение из более раннего шага.', 'whatsNew.v0283.module': 'Единственное исключение составляет вставка модуля: отмена снова выделяет то, что было выделено до вставки, а повтор выделяет вставленные узлы.', + 'whatsNew.v0284.start': 'Начало рисования связи теперь откликается быстрее, особенно в больших диаграммах.', + 'whatsNew.v0284.release': 'Отпускание тоже стало быстрее, создаётся ли связь или её отпускают на пустом холсте; то же при перетаскивании маркера на конце связи.', + 'whatsNew.v0284.same': 'Больше ничего не меняется: пока вы рисуете, по-прежнему предлагаются все стороны всех узлов.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index 4b7a0894..8deb16f1 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -676,6 +676,9 @@ const ui = { 'whatsNew.v0283.hidden': 'หลังเลิกทำหรือทำซ้ำ เส้นเชื่อมหรือโหนดจะไม่ค้างแสดงว่าถูกเลือกขณะที่ตัวตรวจสอบว่างอีกต่อไป จนกว่าจะคลิกเพื่อเลือก Delete จะไม่ลบสิ่งนั้น และปุ่มลูกศรจะไม่ย้ายโหนด', 'whatsNew.v0283.history': 'การเลิกทำและการทำซ้ำจะไม่นำการเลือกจากขั้นตอนก่อนหน้ากลับมาอีก', 'whatsNew.v0283.module': 'การแทรกมอดูลเป็นข้อยกเว้นเพียงกรณีเดียว การเลิกทำจะเลือกสิ่งที่เคยเลือกไว้ก่อนแทรกอีกครั้ง และการทำซ้ำจะเลือกโหนดที่แทรก', + 'whatsNew.v0284.start': 'การเริ่มวาดเส้นเชื่อมตอบสนองเร็วขึ้น โดยเฉพาะในไดอะแกรมขนาดใหญ่', + 'whatsNew.v0284.release': 'การปล่อยก็เร็วขึ้นด้วย ไม่ว่าจะสร้างเส้นเชื่อมได้หรือปล่อยบนพื้นที่ว่าง และการลากจุดจับที่ปลายเส้นเชื่อมก็เช่นกัน', + 'whatsNew.v0284.same': 'นอกนั้นไม่มีอะไรเปลี่ยน ระหว่างวาดยังคงแสดงทุกด้านของทุกโหนดเหมือนเดิม', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 89d2eb04..0753f9ae 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -670,6 +670,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Geri al ya da Yinele sonrasında, İnceleyici boşken bir bağlantı ya da düğüm artık vurgulu kalmaz. Tıklayıp seçene kadar Delete onu silmez, ok tuşları da düğümü taşımaz.', 'whatsNew.v0283.history': 'Geri al ve Yinele artık önceki bir adımdaki seçimi geri getirmez.', 'whatsNew.v0283.module': 'Tek istisna modül eklemedir: Geri al, eklemeden önce seçili olanı yeniden seçer; Yinele ise eklenen düğümleri seçer.', + 'whatsNew.v0284.start': 'Bağlantı çizmeye başlamak artık daha hızlı tepki veriyor, özellikle büyük diyagramlarda.', + 'whatsNew.v0284.release': 'Bırakmak da daha hızlı: bağlantı oluşsa da boş tuvale bırakılsa da; bir ucun tutamacını sürüklemek de aynı şekilde.', + 'whatsNew.v0284.same': 'Başka bir şey değişmez: çizerken tüm düğümlerin tüm kenarları yine sunulur.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index d7fdf3dd..430c8ee5 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -617,6 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Sau khi Hoàn tác hoặc Làm lại, liên kết hay nút không còn được làm nổi khi Bảng thuộc tính trống. Cho đến khi bạn nhấp để chọn, phím Delete không xóa nó và phím mũi tên không di chuyển nút.', 'whatsNew.v0283.history': 'Hoàn tác và Làm lại không còn khôi phục vùng chọn từ một bước trước đó.', 'whatsNew.v0283.module': 'Chèn mô-đun là ngoại lệ duy nhất: Hoàn tác chọn lại những gì đã được chọn trước khi chèn, còn Làm lại chọn các nút vừa chèn.', + 'whatsNew.v0284.start': 'Bắt đầu vẽ liên kết giờ phản hồi nhanh hơn, nhất là trong sơ đồ lớn.', + 'whatsNew.v0284.release': 'Khi thả ra cũng nhanh hơn, dù liên kết được tạo hay được thả trên khung vẽ trống, và kéo tay nắm ở đầu liên kết cũng vậy.', + 'whatsNew.v0284.same': 'Ngoài ra không có gì thay đổi: trong khi vẽ, mọi cạnh của mọi nút vẫn được hiển thị.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index e24af652..3b064718 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -609,6 +609,9 @@ const ui = { 'whatsNew.v0283.hidden': '撤销或重做之后,不会再出现属性面板为空、连线或节点却仍然高亮的情况。在点击选中之前,按 Delete 键不会删除它,方向键也不会移动节点。', 'whatsNew.v0283.history': '撤销和重做不再恢复之前某一步的选中状态。', 'whatsNew.v0283.module': '插入模块是唯一的例外:撤销会重新选中插入前选中的内容,重做会选中插入的节点。', + 'whatsNew.v0284.start': '开始绘制连线时的响应更快了,图表越大越明显。', + 'whatsNew.v0284.release': '松开时也更快了,无论连线是否建立、是否放在空白画布上;拖动连线端点的手柄时也一样。', + 'whatsNew.v0284.same': '其他方面没有变化:绘制时仍会显示每个节点的每条边。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index e6216794..99039ac1 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -634,6 +634,9 @@ const ui = { 'whatsNew.v0283.hidden': '復原或重做之後,不會再出現屬性面板是空的、連線或節點卻仍醒目標示的情況。在點一下選取之前,按 Delete 鍵不會刪除它,方向鍵也不會移動節點。', 'whatsNew.v0283.history': '復原和重做不再恢復先前某一步的選取狀態。', 'whatsNew.v0283.module': '插入模組是唯一的例外:復原會重新選取插入前選取的內容,重做會選取插入的節點。', + 'whatsNew.v0284.start': '開始繪製連線時的反應更快了,圖表越大越明顯。', + 'whatsNew.v0284.release': '放開時也更快了,無論連線是否建立、是否放在空白畫布上;拖曳連線端點的把手時也一樣。', + 'whatsNew.v0284.same': '其他方面沒有變化:繪製時仍會顯示每個節點的每一邊。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index e0cc8522..5a7acb40 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -264,15 +264,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('nl copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) expect(Object.keys(NL).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1145, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1367) + expect(per).toEqual({ catalog: 1148, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1370) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index 15b93e71..8eb2ae40 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // ---------------------------------------------------------------- shape it('has exactly the same key set as pt-BR', () => { expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 1b2039ca..9638de07 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => { // ------------------------------------------------------------------ shape it('has exactly the base key set', () => { expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) // ----------------------------------------------------------------- script @@ -261,6 +261,7 @@ describe('ru copy — the first Cyrillic catalog', () => { 'скруглённые', 'скруглёнными', 'создаёт', + 'создаётся', // 0.28.4: `создаётся ли связь` in the release-note line // issue #302: the 0.15.2 release-note lines, `сохранённая тема` and // `сохранённое значение темы` 'сохранённая', diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 2205a940..64630f5f 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => { describe('th copy — the first no-space script', () => { it('has exactly the base key set', () => { expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1145) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1148) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 6dea5131..b330f637 100644 --- a/src/i18n/trCopy.test.ts +++ b/src/i18n/trCopy.test.ts @@ -30,7 +30,7 @@ const KEYS = Object.keys(EN) describe('tr copy — the first Turkish catalog', () => { it('has exactly the base key set', () => { expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index 0b181df5..1f7069c5 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => { describe('vi copy — the first locale English hides inside', () => { it('has exactly the base key set', () => { expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1145) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1148) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index 73901aa7..c8d70026 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,15 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // a connection drag's start and end measure every node's face handles in + // one update (docs/edge-routing.md §ER17.1, share note 58); the date is + // the day it is deployed, set right before the merge + id: 'release:0.28.4', + version: '0.28.4', + date: '2026-10-17', + items: ['whatsNew.v0284.start', 'whatsNew.v0284.release', 'whatsNew.v0284.same'], + }, { // undo / redo end with nothing selected, a module insert restores its // own selection (docs/large-graph-readability.md LGR-D17, From a7924421b09a5cb5d779996cea32149afde3e02b Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:46:05 +0900 Subject: [PATCH 3/4] chore(release): What's new 0.28.4 says only what was measured and checked (Lumi's review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lumi's review of the report and the measurement record (2026-10-17; not a code review of the product diff): - the second line said that a release which makes a connection, and an end grip drag, got faster; only a drag released on the empty canvas was timed (the others were checked to work, not timed), so it now says that one; - "Nothing else changes" was wider than what was checked; the third line is now the checked contract only: while drawing a connection, handles on every side of every node remain available. English and Korean in Lumi's words; the other 16 follow (the port / point word each locale's UI uses for a handle; pt says "arrastada" for both regions, the pt-PT audit refusing the estar + gerund form). README Latest and the CHANGELOG follow; the CHANGELOG says which gestures were timed. ru drops `создаётся` from its ё list again (the word left the line). No native-speaker review. Checks: tsc, lint, full vitest (3 598 passed, 1 skipped), change declaration, i18n, i18n surface, control bytes, isolate / ICU arguments, locale lists, functional glyphs; whats-new.spec.ts on a fresh dev server, 96 passed. The full local e2e is not rerun for this wording-only change (Lumi). --- CHANGELOG.md | 4 ++-- README.md | 8 ++++---- src/i18n/locales/ar/ui.ts | 6 +++--- src/i18n/locales/de/ui.ts | 6 +++--- src/i18n/locales/en/ui.ts | 6 +++--- src/i18n/locales/es-419/ui.ts | 6 +++--- src/i18n/locales/es-ES/ui.ts | 6 +++--- src/i18n/locales/fr/ui.ts | 6 +++--- src/i18n/locales/it/ui.ts | 6 +++--- src/i18n/locales/ja/ui.ts | 6 +++--- src/i18n/locales/ko/ui.ts | 6 +++--- src/i18n/locales/nl/ui.ts | 6 +++--- src/i18n/locales/pt-BR/ui.ts | 6 +++--- src/i18n/locales/pt-PT/ui.ts | 6 +++--- src/i18n/locales/ru/ui.ts | 6 +++--- src/i18n/locales/th/ui.ts | 6 +++--- src/i18n/locales/tr/ui.ts | 6 +++--- src/i18n/locales/vi/ui.ts | 6 +++--- src/i18n/locales/zh-Hans/ui.ts | 6 +++--- src/i18n/locales/zh-Hant/ui.ts | 6 +++--- src/i18n/ruCopy.test.ts | 1 - 21 files changed, 60 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee44a62..2246933c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ this file is the narrative history, not the contract. Starting and finishing a connection drag responds faster ([`docs/edge-routing.md`](docs/edge-routing.md) §ER17.1). - **What was slow.** While a connection or an end grip is dragged, every node shows all of its sides so the drop can choose one, and when the drag ends the unused ones go. Each node then measured its handles again on its own — one update of the canvas's state per node, each passed to everything listening to it — so the start and the end of a drag grew slow on large diagrams. -- **Now.** One measurement covers every node: at the start at once, before anything is drawn; at the end on the next frame, as before. A new drag started right after one ends is measured on its own. What is offered while drawing, and where a connection lands, are unchanged. -- **How much.** On one machine's production build, the 63-node 3-zone gacha banner comparison started a drag in about 70–80 ms instead of 100–120 ms, and in about 370–460 ms instead of 800–850 ms with the processor slowed four times; releasing improved about as much; Coffee roastery operations flow did not get slower. The full measurement is kept with the work, not in the app. +- **Now.** One measurement covers every node: at the start at once, before anything is drawn; at the end on the next frame, as before. A new drag started right after one ends is measured on its own. While drawing, every side of every node is still offered. +- **How much.** On one machine's production build, the 63-node 3-zone gacha banner comparison started a drag in about 70–80 ms instead of 100–120 ms, and in about 370–460 ms instead of 800–850 ms with the processor slowed four times; releasing the drag on the empty canvas improved about as much; Coffee roastery operations flow did not get slower. Only those two gestures were timed: a release that makes a connection and an end grip drag take the same path and were checked to work as before, not timed. The full measurement is kept with the work, not in the app. **Notes.** Three What's new lines in 18 languages: the translation contract tests pass, and none had native-speaker review. diff --git a/README.md b/README.md index 89f21e95..7a3c9c74 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,10 @@ import, …) live under [`docs/`](docs/). Starting and finishing a connection responds faster. -- **Starting to draw**: responds faster, especially in large diagrams -- **Letting go**: faster too, whether the connection is made or dropped on the empty canvas, - and so is dragging a connection's end grip -- **Nothing else changes**: while you draw, every side of every node is still offered +- **Starting to draw**: responds faster, with a larger improvement in large diagrams +- **Releasing on the empty canvas**: a connection drag released there also responds faster +- **Every side stays available**: while drawing a connection, handles on every side of every + node remain available ## v0.28.3 diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 4f81ccd5..0ac56411 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -656,9 +656,9 @@ const ui = { 'whatsNew.v0283.hidden': 'بعد التراجع أو الإعادة لم يعد الرابط أو العقدة يبقى مُبرَزًا بينما المفتّش فارغ. وإلى أن تنقر عليه لتحديده، لا يحذفه المفتاح Delete، ولا تحرّك مفاتيح الأسهم العقدة.', 'whatsNew.v0283.history': 'لم يعد التراجع والإعادة يعيدان تحديدًا من خطوة سابقة.', 'whatsNew.v0283.module': 'الاستثناء الوحيد هو إدراج وحدة: يحدد التراجع من جديد ما كان محددًا قبل الإدراج، وتحدد الإعادة العُقد المُدرجة.', - 'whatsNew.v0284.start': 'أصبح بدء رسم الرابط يستجيب أسرع، ولا سيما في المخططات الكبيرة.', - 'whatsNew.v0284.release': 'وأصبح الإفلات أسرع أيضًا، سواء أُنشئ الرابط أم أُفلت على لوحة فارغة، وكذلك سحب المقبض في طرف الرابط.', - 'whatsNew.v0284.same': 'لا شيء آخر يتغيّر: أثناء الرسم تظل كل جوانب كل العقد معروضة.', + 'whatsNew.v0284.start': 'أصبح بدء رسم الرابط يستجيب أسرع، ويكون التحسّن أكبر في المخططات الكبيرة.', + 'whatsNew.v0284.release': 'وأصبح إفلات رابط مسحوب على لوحة فارغة يستجيب أسرع أيضًا.', + 'whatsNew.v0284.same': 'أثناء رسم رابط تبقى المنافذ على كل جوانب كل العقد متاحة.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index fe5deb41..fe95aba8 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -691,9 +691,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Nach Rückgängig oder Wiederherstellen bleibt keine Verbindung und kein Knoten mehr hervorgehoben, während die Eigenschaften leer sind. Bis Sie das Element anklicken und damit auswählen, entfernt Entf es nicht, und die Pfeiltasten bewegen keinen Knoten.', 'whatsNew.v0283.history': 'Rückgängig und Wiederherstellen holen keine Auswahl aus einem früheren Schritt mehr zurück.', 'whatsNew.v0283.module': 'Einzige Ausnahme ist das Einfügen eines Moduls: Rückgängig wählt wieder aus, was vor dem Einfügen ausgewählt war, und Wiederherstellen wählt die eingefügten Knoten aus.', - 'whatsNew.v0284.start': 'Wenn Sie beginnen, eine Verbindung zu zeichnen, reagiert die Ansicht jetzt schneller, besonders in großen Diagrammen.', - 'whatsNew.v0284.release': 'Auch das Loslassen reagiert schneller, ob die Verbindung entsteht oder Sie auf der leeren Arbeitsfläche loslassen; ebenso das Ziehen am Griff eines Verbindungsendes.', - 'whatsNew.v0284.same': 'Sonst ändert sich nichts: Während Sie zeichnen, werden weiterhin alle Seiten aller Knoten angeboten.', + 'whatsNew.v0284.start': 'Wenn Sie beginnen, eine Verbindung zu zeichnen, reagiert die Ansicht schneller, in großen Diagrammen besonders deutlich.', + 'whatsNew.v0284.release': 'Auch das Loslassen einer gezogenen Verbindung auf der leeren Arbeitsfläche reagiert schneller.', + 'whatsNew.v0284.same': 'Während Sie eine Verbindung zeichnen, bleiben die Verbindungspunkte auf allen Seiten aller Knoten verfügbar.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 225e4d41..3c29d806 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -601,9 +601,9 @@ const ui = { 'whatsNew.v0283.hidden': 'After Undo or Redo, a connection or node no longer stays highlighted while the Inspector is empty. Delete won’t remove it, and the arrow keys won’t move a node until you select it.', 'whatsNew.v0283.history': 'Undo and Redo no longer bring back a selection from an earlier step.', 'whatsNew.v0283.module': 'Inserting a module is the one exception: Undo selects again what was selected before the insert, and Redo selects the inserted nodes.', - 'whatsNew.v0284.start': 'Starting to draw a connection now responds faster, especially in large diagrams.', - 'whatsNew.v0284.release': 'Letting go responds faster too, whether the connection is made or you drop it on the empty canvas, and so does dragging a connection’s end grip.', - 'whatsNew.v0284.same': 'Nothing else changes: while you draw, every side of every node is still offered.', + 'whatsNew.v0284.start': 'Starting to draw a connection responds faster, with a larger improvement in large diagrams.', + 'whatsNew.v0284.release': 'Releasing a connection drag on the empty canvas also responds faster.', + 'whatsNew.v0284.same': 'While drawing a connection, handles on every side of every node remain available.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index c749fc8d..09481191 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -615,9 +615,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', - 'whatsNew.v0284.start': 'Empezar a dibujar una conexión ahora responde más rápido, sobre todo en diagramas grandes.', - 'whatsNew.v0284.release': 'Soltar también responde más rápido, ya sea que se cree la conexión o que la suelte en el lienzo vacío, y lo mismo al arrastrar el asa de un extremo.', - 'whatsNew.v0284.same': 'Nada más cambia: mientras dibuja, se siguen ofreciendo todos los lados de todos los nodos.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión responde más rápido, con una mejora mayor en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar en el lienzo vacío una conexión que se está arrastrando también responde más rápido.', + 'whatsNew.v0284.same': 'Mientras dibuja una conexión, los puertos de todos los lados de todos los nodos siguen disponibles.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index 7bfbaa9d..9decdf1b 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -629,9 +629,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', - 'whatsNew.v0284.start': 'Empezar a dibujar una conexión ahora responde más rápido, sobre todo en diagramas grandes.', - 'whatsNew.v0284.release': 'Soltar también responde más rápido, ya sea que se cree la conexión o que la suelte en el lienzo vacío, y lo mismo al arrastrar el asa de un extremo.', - 'whatsNew.v0284.same': 'Nada más cambia: mientras dibuja, se siguen ofreciendo todos los lados de todos los nodos.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión responde más rápido, con una mejora mayor en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar en el lienzo vacío una conexión que se está arrastrando también responde más rápido.', + 'whatsNew.v0284.same': 'Mientras dibuja una conexión, los puertos de todos los lados de todos los nodos siguen disponibles.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index 37277490..63f38683 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -685,9 +685,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Après Annuler ou Rétablir, une connexion ou un nœud ne reste plus en surbrillance alors que les Propriétés n’affichent rien. Tant que vous ne cliquez pas dessus pour le sélectionner, Suppr ne le supprime pas et les flèches ne déplacent aucun nœud.', 'whatsNew.v0283.history': 'Annuler et Rétablir ne ramènent plus une sélection d’une étape antérieure.', 'whatsNew.v0283.module': 'L’insertion d’un module est la seule exception. Annuler sélectionne de nouveau ce qui l’était avant l’insertion, et Rétablir sélectionne les nœuds insérés.', - 'whatsNew.v0284.start': 'Commencer à tracer une connexion réagit désormais plus vite, surtout dans les grands diagrammes.', - 'whatsNew.v0284.release': 'Le relâchement réagit aussi plus vite, que la connexion soit créée ou relâchée sur le canevas vide, de même que le glissement de la poignée d’une extrémité.', - 'whatsNew.v0284.same': 'Rien d’autre ne change. Pendant le tracé, tous les côtés de tous les nœuds restent proposés.', + 'whatsNew.v0284.start': 'Commencer à tracer une connexion réagit plus vite, et l’amélioration est plus nette dans les grands diagrammes.', + 'whatsNew.v0284.release': 'Relâcher une connexion en cours de tracé sur le canevas vide réagit aussi plus vite.', + 'whatsNew.v0284.same': 'Pendant le tracé d’une connexion, les points de connexion de tous les côtés de tous les nœuds restent disponibles.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 1e9ec461..21f71ecd 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -631,9 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Dopo Annulla o Ripeti, un collegamento o un nodo non resta più evidenziato mentre l’Ispettore non mostra nulla. Finché non ci fai clic per selezionarlo, Canc non lo rimuove e i tasti freccia non spostano nessun nodo.', 'whatsNew.v0283.history': 'Annulla e Ripeti non riportano più una selezione di un passaggio precedente.', 'whatsNew.v0283.module': 'L’unica eccezione è l’inserimento di un modulo: Annulla seleziona di nuovo ciò che era selezionato prima dell’inserimento, e Ripeti seleziona i nodi inseriti.', - 'whatsNew.v0284.start': 'Iniziare a disegnare un collegamento ora risponde più rapidamente, soprattutto nei diagrammi grandi.', - 'whatsNew.v0284.release': 'Anche il rilascio è più rapido, sia che il collegamento venga creato sia che lo lasci sull’area di disegno vuota, e così trascinare la maniglia di un estremo.', - 'whatsNew.v0284.same': 'Nient’altro cambia: mentre disegni, tutti i lati di tutti i nodi restano disponibili.', + 'whatsNew.v0284.start': 'Iniziare a disegnare un collegamento risponde più rapidamente, con un miglioramento maggiore nei diagrammi grandi.', + 'whatsNew.v0284.release': 'Anche rilasciare sull’area di disegno vuota un collegamento che stai trascinando risponde più rapidamente.', + 'whatsNew.v0284.same': 'Mentre disegni un collegamento, le porte su tutti i lati di tutti i nodi restano disponibili.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 30f254f7..33c33ca4 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -636,9 +636,9 @@ const ui = { 'whatsNew.v0283.hidden': '元に戻す・やり直すの後、インスペクターが空なのに接続やノードが強調されたまま残ることはなくなりました。クリックして選択するまで、Delete で削除されることも、ノードが矢印キーで動くこともありません。', 'whatsNew.v0283.history': '元に戻す・やり直すで、以前の手順の選択が戻ってくることはなくなりました。', 'whatsNew.v0283.module': 'モジュールの挿入だけは例外です。元に戻すと挿入前に選択していたものが再び選択され、やり直すと挿入したノードが選択されます。', - 'whatsNew.v0284.start': '接続を描き始めるときの反応が速くなりました。大きなダイアグラムほど効果があります。', - 'whatsNew.v0284.release': '離したときの反応も速くなりました。接続ができたときも空のキャンバスで離したときも同じで、接続の端のつまみをドラッグするときも同様です。', - 'whatsNew.v0284.same': 'それ以外は変わりません。描いている間は、これまでどおりすべてのノードのすべての辺が表示されます。', + 'whatsNew.v0284.start': '接続を描き始めるときの反応が速くなりました。大きなダイアグラムほど改善の幅が大きくなります。', + 'whatsNew.v0284.release': '接続をドラッグして空のキャンバスで離したときも、反応が速くなりました。', + 'whatsNew.v0284.same': '接続を描いている間は、すべてのノードのすべての辺にあるハンドルを引き続き使えます。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index b37f5763..64bb9b02 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -604,9 +604,9 @@ const ui = { 'whatsNew.v0283.hidden': '실행 취소나 다시 실행 뒤, 인스펙터는 비어 있는데 연결이나 노드가 강조된 채 남는 일이 없습니다. 클릭해서 선택하기 전에는 Delete로 지워지지 않고, 노드가 화살표 키로 움직이지도 않습니다.', 'whatsNew.v0283.history': '실행 취소와 다시 실행이 이전 단계의 선택을 되살리지 않습니다.', 'whatsNew.v0283.module': '모듈 삽입만 예외입니다. 실행 취소하면 삽입 전에 선택했던 것이 다시 선택되고, 다시 실행하면 삽입한 노드가 선택됩니다.', - 'whatsNew.v0284.start': '연결을 그리기 시작할 때의 반응이 빨라졌습니다. 큰 다이어그램일수록 차이가 큽니다.', - 'whatsNew.v0284.release': '연결을 놓을 때도 빨라졌습니다. 연결이 만들어질 때와 빈 캔버스에 놓을 때 모두 해당하며, 연결 끝의 잡는 점을 끌 때도 같습니다.', - 'whatsNew.v0284.same': '그 밖에 달라진 것은 없습니다. 그리는 동안 모든 노드의 모든 면이 그대로 표시됩니다.', + 'whatsNew.v0284.start': '연결을 그리기 시작할 때 반응이 빨라졌으며, 큰 다이어그램에서 개선 폭이 더 큽니다.', + 'whatsNew.v0284.release': '연결을 끌다가 빈 캔버스에 놓을 때도 반응이 빨라졌습니다.', + 'whatsNew.v0284.same': '연결을 그리는 동안 모든 노드의 모든 면에 있는 핸들은 계속 표시됩니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 9d4cf381..9c0bc0f6 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -612,9 +612,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Na Ongedaan maken of Opnieuw uitvoeren blijft een verbinding of knooppunt niet meer gemarkeerd terwijl de Inspector niets toont. Tot je erop klikt om het te selecteren, verwijdert Delete het niet en verplaatsen de pijltoetsen geen knooppunt.', 'whatsNew.v0283.history': 'Ongedaan maken en Opnieuw uitvoeren halen geen selectie uit een eerdere stap meer terug.', 'whatsNew.v0283.module': 'Een module invoegen is de enige uitzondering: Ongedaan maken herstelt de selectie van vóór het invoegen, en Opnieuw uitvoeren selecteert de ingevoegde knooppunten.', - 'whatsNew.v0284.start': 'Beginnen met het tekenen van een verbinding reageert nu sneller, vooral in grote diagrammen.', - 'whatsNew.v0284.release': 'Ook loslaten reageert sneller, of de verbinding nu ontstaat of je haar op het lege tekengebied loslaat, en net zo het slepen aan de greep van een uiteinde.', - 'whatsNew.v0284.same': 'Verder verandert er niets: tijdens het tekenen worden nog steeds alle zijden van alle knooppunten aangeboden.', + 'whatsNew.v0284.start': 'Beginnen met het tekenen van een verbinding reageert sneller, met een grotere verbetering in grote diagrammen.', + 'whatsNew.v0284.release': 'Ook een gesleepte verbinding loslaten op het lege tekengebied reageert sneller.', + 'whatsNew.v0284.same': 'Tijdens het tekenen van een verbinding blijven de poorten op alle zijden van alle knooppunten beschikbaar.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 5b657e86..ab935ed6 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -617,9 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', - 'whatsNew.v0284.start': 'Começar a desenhar uma ligação passa a responder mais rapidamente, sobretudo em diagramas grandes.', - 'whatsNew.v0284.release': 'Soltar também responde mais rapidamente, quer a ligação seja criada, quer seja solta na tela vazia, e o mesmo acontece ao arrastar a alça de uma extremidade.', - 'whatsNew.v0284.same': 'Nada mais muda: enquanto desenha, todos os lados de todos os nós continuam a ser oferecidos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação responde mais rapidamente, com uma melhoria maior em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar uma ligação arrastada na tela vazia também responde mais rapidamente.', + 'whatsNew.v0284.same': 'Enquanto desenha uma ligação, as portas de todos os lados de todos os nós continuam disponíveis.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index d9fab158..4ed9f224 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -631,9 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', - 'whatsNew.v0284.start': 'Começar a desenhar uma ligação passa a responder mais rapidamente, sobretudo em diagramas grandes.', - 'whatsNew.v0284.release': 'Soltar também responde mais rapidamente, quer a ligação seja criada, quer seja solta na tela vazia, e o mesmo acontece ao arrastar a alça de uma extremidade.', - 'whatsNew.v0284.same': 'Nada mais muda: enquanto desenha, todos os lados de todos os nós continuam a ser oferecidos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação responde mais rapidamente, com uma melhoria maior em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar uma ligação arrastada na tela vazia também responde mais rapidamente.', + 'whatsNew.v0284.same': 'Enquanto desenha uma ligação, as portas de todos os lados de todos os nós continuam disponíveis.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index 2a4e0196..21748334 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -684,9 +684,9 @@ const ui = { 'whatsNew.v0283.hidden': 'После отмены или повтора связь или узел больше не остаётся подсвеченным, пока инспектор пуст. Пока вы не выделите элемент щелчком, Delete его не удалит, а стрелки не сдвинут узел.', 'whatsNew.v0283.history': 'Отмена и повтор больше не возвращают выделение из более раннего шага.', 'whatsNew.v0283.module': 'Единственное исключение составляет вставка модуля: отмена снова выделяет то, что было выделено до вставки, а повтор выделяет вставленные узлы.', - 'whatsNew.v0284.start': 'Начало рисования связи теперь откликается быстрее, особенно в больших диаграммах.', - 'whatsNew.v0284.release': 'Отпускание тоже стало быстрее, создаётся ли связь или её отпускают на пустом холсте; то же при перетаскивании маркера на конце связи.', - 'whatsNew.v0284.same': 'Больше ничего не меняется: пока вы рисуете, по-прежнему предлагаются все стороны всех узлов.', + 'whatsNew.v0284.start': 'Начало рисования связи откликается быстрее, и в больших диаграммах улучшение заметнее.', + 'whatsNew.v0284.release': 'Отпускание перетаскиваемой связи на пустом холсте тоже откликается быстрее.', + 'whatsNew.v0284.same': 'Пока вы рисуете связь, порты на всех сторонах всех узлов остаются доступными.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index 8deb16f1..2fa5b9c3 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -676,9 +676,9 @@ const ui = { 'whatsNew.v0283.hidden': 'หลังเลิกทำหรือทำซ้ำ เส้นเชื่อมหรือโหนดจะไม่ค้างแสดงว่าถูกเลือกขณะที่ตัวตรวจสอบว่างอีกต่อไป จนกว่าจะคลิกเพื่อเลือก Delete จะไม่ลบสิ่งนั้น และปุ่มลูกศรจะไม่ย้ายโหนด', 'whatsNew.v0283.history': 'การเลิกทำและการทำซ้ำจะไม่นำการเลือกจากขั้นตอนก่อนหน้ากลับมาอีก', 'whatsNew.v0283.module': 'การแทรกมอดูลเป็นข้อยกเว้นเพียงกรณีเดียว การเลิกทำจะเลือกสิ่งที่เคยเลือกไว้ก่อนแทรกอีกครั้ง และการทำซ้ำจะเลือกโหนดที่แทรก', - 'whatsNew.v0284.start': 'การเริ่มวาดเส้นเชื่อมตอบสนองเร็วขึ้น โดยเฉพาะในไดอะแกรมขนาดใหญ่', - 'whatsNew.v0284.release': 'การปล่อยก็เร็วขึ้นด้วย ไม่ว่าจะสร้างเส้นเชื่อมได้หรือปล่อยบนพื้นที่ว่าง และการลากจุดจับที่ปลายเส้นเชื่อมก็เช่นกัน', - 'whatsNew.v0284.same': 'นอกนั้นไม่มีอะไรเปลี่ยน ระหว่างวาดยังคงแสดงทุกด้านของทุกโหนดเหมือนเดิม', + 'whatsNew.v0284.start': 'การเริ่มวาดเส้นเชื่อมตอบสนองเร็วขึ้น และยิ่งไดอะแกรมใหญ่ยิ่งเห็นผลชัด', + 'whatsNew.v0284.release': 'การปล่อยเส้นเชื่อมที่กำลังลากบนพื้นที่ว่างก็ตอบสนองเร็วขึ้นเช่นกัน', + 'whatsNew.v0284.same': 'ระหว่างวาดเส้นเชื่อม จุดเชื่อมทุกด้านของทุกโหนดยังคงใช้ได้', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 0753f9ae..4da624fa 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -670,9 +670,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Geri al ya da Yinele sonrasında, İnceleyici boşken bir bağlantı ya da düğüm artık vurgulu kalmaz. Tıklayıp seçene kadar Delete onu silmez, ok tuşları da düğümü taşımaz.', 'whatsNew.v0283.history': 'Geri al ve Yinele artık önceki bir adımdaki seçimi geri getirmez.', 'whatsNew.v0283.module': 'Tek istisna modül eklemedir: Geri al, eklemeden önce seçili olanı yeniden seçer; Yinele ise eklenen düğümleri seçer.', - 'whatsNew.v0284.start': 'Bağlantı çizmeye başlamak artık daha hızlı tepki veriyor, özellikle büyük diyagramlarda.', - 'whatsNew.v0284.release': 'Bırakmak da daha hızlı: bağlantı oluşsa da boş tuvale bırakılsa da; bir ucun tutamacını sürüklemek de aynı şekilde.', - 'whatsNew.v0284.same': 'Başka bir şey değişmez: çizerken tüm düğümlerin tüm kenarları yine sunulur.', + 'whatsNew.v0284.start': 'Bağlantı çizmeye başlamak daha hızlı tepki veriyor; büyük diyagramlarda iyileşme daha belirgin.', + 'whatsNew.v0284.release': 'Sürüklenen bir bağlantıyı boş tuvale bırakmak da daha hızlı tepki veriyor.', + 'whatsNew.v0284.same': 'Bağlantı çizerken tüm düğümlerin tüm kenarlarındaki bağlantı noktaları kullanılabilir kalır.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 430c8ee5..9f4bb55b 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -617,9 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Sau khi Hoàn tác hoặc Làm lại, liên kết hay nút không còn được làm nổi khi Bảng thuộc tính trống. Cho đến khi bạn nhấp để chọn, phím Delete không xóa nó và phím mũi tên không di chuyển nút.', 'whatsNew.v0283.history': 'Hoàn tác và Làm lại không còn khôi phục vùng chọn từ một bước trước đó.', 'whatsNew.v0283.module': 'Chèn mô-đun là ngoại lệ duy nhất: Hoàn tác chọn lại những gì đã được chọn trước khi chèn, còn Làm lại chọn các nút vừa chèn.', - 'whatsNew.v0284.start': 'Bắt đầu vẽ liên kết giờ phản hồi nhanh hơn, nhất là trong sơ đồ lớn.', - 'whatsNew.v0284.release': 'Khi thả ra cũng nhanh hơn, dù liên kết được tạo hay được thả trên khung vẽ trống, và kéo tay nắm ở đầu liên kết cũng vậy.', - 'whatsNew.v0284.same': 'Ngoài ra không có gì thay đổi: trong khi vẽ, mọi cạnh của mọi nút vẫn được hiển thị.', + 'whatsNew.v0284.start': 'Bắt đầu vẽ liên kết phản hồi nhanh hơn, và sơ đồ càng lớn thì cải thiện càng rõ.', + 'whatsNew.v0284.release': 'Thả một liên kết đang kéo trên khung vẽ trống cũng phản hồi nhanh hơn.', + 'whatsNew.v0284.same': 'Trong khi vẽ liên kết, cổng kết nối ở mọi cạnh của mọi nút vẫn dùng được.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index 3b064718..452143f0 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -609,9 +609,9 @@ const ui = { 'whatsNew.v0283.hidden': '撤销或重做之后,不会再出现属性面板为空、连线或节点却仍然高亮的情况。在点击选中之前,按 Delete 键不会删除它,方向键也不会移动节点。', 'whatsNew.v0283.history': '撤销和重做不再恢复之前某一步的选中状态。', 'whatsNew.v0283.module': '插入模块是唯一的例外:撤销会重新选中插入前选中的内容,重做会选中插入的节点。', - 'whatsNew.v0284.start': '开始绘制连线时的响应更快了,图表越大越明显。', - 'whatsNew.v0284.release': '松开时也更快了,无论连线是否建立、是否放在空白画布上;拖动连线端点的手柄时也一样。', - 'whatsNew.v0284.same': '其他方面没有变化:绘制时仍会显示每个节点的每条边。', + 'whatsNew.v0284.start': '开始绘制连线时的响应更快了,图表越大,改善越明显。', + 'whatsNew.v0284.release': '拖动连线后在空白画布上松开时,响应也更快了。', + 'whatsNew.v0284.same': '绘制连线时,每个节点每条边上的端口仍然可用。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 99039ac1..53a6741e 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -634,9 +634,9 @@ const ui = { 'whatsNew.v0283.hidden': '復原或重做之後,不會再出現屬性面板是空的、連線或節點卻仍醒目標示的情況。在點一下選取之前,按 Delete 鍵不會刪除它,方向鍵也不會移動節點。', 'whatsNew.v0283.history': '復原和重做不再恢復先前某一步的選取狀態。', 'whatsNew.v0283.module': '插入模組是唯一的例外:復原會重新選取插入前選取的內容,重做會選取插入的節點。', - 'whatsNew.v0284.start': '開始繪製連線時的反應更快了,圖表越大越明顯。', - 'whatsNew.v0284.release': '放開時也更快了,無論連線是否建立、是否放在空白畫布上;拖曳連線端點的把手時也一樣。', - 'whatsNew.v0284.same': '其他方面沒有變化:繪製時仍會顯示每個節點的每一邊。', + 'whatsNew.v0284.start': '開始繪製連線時的反應更快了,圖表越大,改善越明顯。', + 'whatsNew.v0284.release': '拖曳連線後在空白畫布上放開時,反應也更快了。', + 'whatsNew.v0284.same': '繪製連線時,每個節點每一邊上的連接點仍然可用。', } satisfies Record export default ui diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 9638de07..fde42f99 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -261,7 +261,6 @@ describe('ru copy — the first Cyrillic catalog', () => { 'скруглённые', 'скруглёнными', 'создаёт', - 'создаётся', // 0.28.4: `создаётся ли связь` in the release-note line // issue #302: the 0.15.2 release-note lines, `сохранённая тема` and // `сохранённое значение темы` 'сохранённая', From 638075529d481cc16f9c512e0d4d69cc398b8635 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:30:48 +0900 Subject: [PATCH 4/4] fix(nodes): the drag measurement's state is each canvas's, and a drag ends when React Flow's connection ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in my own review of the diff, on the points Lumi named for the code review (state per canvas; a waiting measurement on restart, document replacement and unmount). `faceBatch.ts` kept one module-wide flag "this drag is measured", turned on at a start and off at an end reported by a node. If no node saw the end — every node gone during the drag (its insert undone with Ctrl+Z, or the node structure switched) — the flag stayed on, and the NEXT drag's start was taken for "already measured": no node's faces were measured, so React Flow could not find an unused face to drop on. Reproduced before the fix in a unit test and in an e2e (a module inserted into an empty document, a drag from one of its nodes, Ctrl+Z while held, release, Ctrl+Y, a new drag: the target's unused face never gets `connectingto`). Now: - a drag also ends when React Flow's `connection.inProgress` goes false (a store subscription, one per canvas), so the next start is measured even when no node saw the end; the end seen by the store and by the nodes is one measurement; - the state is each canvas's, keyed by the store's own `getState`. `useStoreApi()` gives every component its own `{ getState, setState, subscribe }` wrapper (memoized per hook call): keyed by the wrapper, every node had its own state and nothing was merged — a first attempt keyed that way measured all 63 gacha nodes 61 times per start (counted) and made a start about 3 times and a release about 6 times slower than main (production, x1); never committed; - unchanged: a start measures every node at once, an end on the next frame, a start cancels an end's frame that has not run. A frame is not cancelled when the canvas goes away; it runs, reads the store and the DOM then, finds no `domNode` (React Flow resets its store on unmount) and measures nothing. Counted again (dev, diagnostic copies, never in a timing run): one measurement of every node per start and per release, as before the fix. Tests: - `faceBatch.test.ts` 11 (was 6): + requests of one start in separate tasks are still one measurement; + an end seen by React Flow and by the nodes is one measurement; + a drag whose end no node reported is over when React Flow ends it; + every node's own wrapper of one store is one canvas; + two canvases are measured apart; + an end with no drag begun measures nothing. The test-only reset is gone (each test makes its own store). The module-wide version fails 6 of them, the per-task (microtask) version 3, the wrapper-keyed version 1. - `e2e/connection-draw-faces.spec.ts` 6 (was 5): + every node gone during a drag (its insert undone) does not stop the next drag measuring every node; it fails on the previous faceBatch and passes now. After the redo a click on the empty pane did not clear the restored selection there (not traced, left open), so the test clears it through the store; `emptySpot` now looks for a point where the pane is on top. - `docs/edge-routing.md` §ER17.1 says how a drag ends and what the state is keyed by. Production timing of this code against main (base twice, this twice, interleaved, medians of eight drags): gacha start 86.8-92.2 -> 62.8-62.9 ms and release 85.3-87.6 -> 61.6-64.4 ms at CPU x1; 502.7-506.0 -> 285.6-291.1 ms and 438.4-471.3 -> 213.4-228.1 ms at x4; Coffee not slower. Checks: tsc, lint, full vitest (3 603 passed, 1 skipped), e2e shards, control bytes; dev, fresh server: the new spec and the 9 related specs, 110 passed. --- docs/edge-routing.md | 8 +- e2e/connection-draw-faces.spec.ts | 83 +++++++++++++++++- src/components/nodes/faceBatch.test.ts | 114 +++++++++++++++++++++---- src/components/nodes/faceBatch.ts | 109 ++++++++++++++--------- 4 files changed, 252 insertions(+), 62 deletions(-) diff --git a/docs/edge-routing.md b/docs/edge-routing.md index 08b962bd..4a70cb6c 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -1021,7 +1021,13 @@ the handles' bounds are measured again: first node whose layout effect sees the drag state change measures every node in one `updateNodeInternals` call — at the start at once (the commit's DOM is complete, nothing painted), at the end on the next frame (only - removals) — and a new drag cancels an end's measurement still waiting. + removals) — and a new drag cancels an end's frame that has not run yet (a + frame left when the canvas goes away still runs, finds no canvas and + measures nothing). The state is each canvas's, keyed by the store's own + `getState` (`useStoreApi()` gives every component its own wrapper object), + and a drag also ends when React Flow's `connection.inProgress` goes false, + so the next drag is measured even when no node saw the last one end (every + node gone during the drag, e.g. its insert undone). Measured one node at a time, each was one React Flow store update run through every subscriber, and starting or ending a drag on the 63-node gacha banner Template took about 1.5 to 2.5 times as long, on the production build diff --git a/e2e/connection-draw-faces.spec.ts b/e2e/connection-draw-faces.spec.ts index a5467a1a..cf4279d8 100644 --- a/e2e/connection-draw-faces.spec.ts +++ b/e2e/connection-draw-faces.spec.ts @@ -56,10 +56,18 @@ const centreOf = async (page: Page, node: string, handle: string) => { return { x: b.x + b.width / 2, y: b.y + b.height / 2 } } -/** an empty spot of the pane, away from every node and line */ +/** an empty spot of the pane, away from every node and line: the first point + * of a grid, from the lower right, where the pane itself is on top */ async function emptySpot(page: Page) { - const b = (await page.locator('.react-flow__pane').boundingBox())! - return { x: b.x + b.width - 380, y: b.y + b.height - 40 } + const p = await page.evaluate(() => { + const r = document.querySelector('.react-flow__pane')!.getBoundingClientRect() + for (let y = r.bottom - 40; y > r.top + 40; y -= 24) + for (let x = r.right - 380; x > r.left + 40; x -= 24) + if (document.elementFromPoint(x, y)?.classList.contains('react-flow__pane')) return { x, y } + return null + }) + expect(p, 'an empty spot of the pane').not.toBeNull() + return p! } /** after a drop: nothing selected, nothing focused, so each node shows the faces its connections use */ @@ -237,6 +245,75 @@ test.describe('drawing a connection: the face handles of every node (share note expect(warnings).toEqual([]) }) + test('every node gone during a drag (its insert undone) does not stop the next drag measuring every node', async ({ page }) => { + const warnings: string[] = [] + page.on('console', (m) => { + if (/Couldn't create edge|error#008/.test(m.text())) warnings.push(m.text().slice(0, 160)) + }) + await openApp(page) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().newGraph()) + await expect(page.locator('.react-flow__node')).toHaveCount(0) + // one undo step that adds every node: a module insert + const menu = page.locator('.toolbar__actions .menu', { has: page.getByRole('button', { name: 'Insert module' }) }) + await menu.getByRole('button', { name: 'Insert module' }).click() + await menu.locator('.menu__item').filter({ has: page.locator('.menu__name', { hasText: 'Reward split loop' }) }).click() + await expect.poll(() => page.locator('.react-flow__node').count()).toBeGreaterThan(2) + await routesSettled(page) + await settle(page) + const ids = await page.evaluate(() => + [...document.querySelectorAll('.react-flow__node')].map((n) => ({ + id: (n as HTMLElement).dataset.id!, + out: !!n.querySelector('.react-flow__handle[data-handleid="out"]'), + in: !!n.querySelector('.react-flow__handle[data-handleid="in"]'), + })), + ) + const from = ids.find((n) => n.out)! + const to = ids.find((n) => n.in && n.id !== from.id)! + // a drag from a node, then Ctrl+Z takes every node away while it is held, + // and the drag is released on the empty canvas: no node sees it end + const a = await centreOf(page, from.id, 'out') + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + await expect.poll(async () => (await mountedFaces(page))[to.id]?.length ?? 0).toBeGreaterThan(0) + await page.keyboard.press('Control+z') + await expect(page.locator('.react-flow__node')).toHaveCount(0) + const e = await emptySpot(page) + await page.mouse.move(e.x, e.y, { steps: 4 }) + await page.mouse.up() + // the nodes come back; a new drag must offer — and React Flow must find — an unused face of another node + await page.keyboard.press('Control+y') + await expect(page.locator('.react-flow__node')).toHaveCount(ids.length) + await routesSettled(page) + // the redo selects the inserted nodes again (§MS3.5): clear that through + // the store (a click on the empty pane did not clear it here — not traced), + // so the target's faces are the used ones until the drag starts + await page.evaluate(() => { + const g = (window as unknown as { __loop: L }).__loop.graph.getState() + g.onNodesChange(g.nodes.map((n: any) => ({ type: 'select', id: n.id, selected: false }))) + g.setSelection(null, null) + }) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().nodes.filter((n: any) => n.selected).length)) + .toBe(0) + await expect.poll(async () => ((await mountedFaces(page))[to.id] ?? []).length).toBeLessThan(12) + const rest = (await mountedFaces(page))[to.id] ?? [] + const b0 = await centreOf(page, from.id, 'out') + await page.mouse.move(b0.x, b0.y) + await page.mouse.down() + await page.mouse.move(b0.x + 30, b0.y + 30, { steps: 4 }) + await expect.poll(async () => (await mountedFaces(page))[to.id]?.length ?? 0).toBeGreaterThan(rest.length) + const unused = ((await mountedFaces(page))[to.id] ?? []).find((h) => h.startsWith('in@') && !rest.includes(h))! + expect(unused, 'an unused input face of the target').toBeTruthy() + const t = await centreOf(page, to.id, unused) + await page.mouse.move(t.x, t.y, { steps: 8 }) + await expect(face(page, to.id, unused), 'measured at this drag\'s start, so React Flow picks it').toHaveClass(/\bconnectingto\b/) + await page.mouse.move(e.x, e.y, { steps: 4 }) + await page.mouse.up() + expect(warnings).toEqual([]) + }) + test('Esc cancels an end grip drag, the faces go back, and the next connection is made', async ({ page }) => { const warnings: string[] = [] await open(page, warnings) diff --git a/src/components/nodes/faceBatch.test.ts b/src/components/nodes/faceBatch.test.ts index a967e7b3..21b144ae 100644 --- a/src/components/nodes/faceBatch.test.ts +++ b/src/components/nodes/faceBatch.test.ts @@ -1,22 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { __resetFaceBatchForTests, measureFacesForDrag } from './faceBatch' +import { measureFacesForDrag } from './faceBatch' // ./faceBatch (share note 58): when a connection drag begins or ends, every -// node's layout effect asks for its faces to be measured; the requests of one -// commit become ONE React Flow `updateNodeInternals` call holding every node -// on the canvas — at once for a start, on the next animation frame for an end -// — and a start cancels an end's measurement still waiting. What is pinned is -// the measurement calls and the nodes in them, not React Flow's own store -// updates (it makes others at a start and a release). +// node's layout effect asks for its faces to be measured; those requests +// become ONE React Flow `updateNodeInternals` call holding every node on the +// canvas — at once for a drag's start, on the next animation frame for its +// end — a start cancels an end's frame still waiting, and a drag also ends +// when React Flow's connection ends with no node asking. What is pinned is the +// measurement calls and the nodes in them, not React Flow's own store updates +// (it makes others at a start and a release). type Update = Map +type Conn = { connection: { inProgress: boolean } } -/** a canvas with node elements; `undefined` is an element without a node id */ +/** a canvas with node elements (`undefined` is an element without a node id) + * and a React Flow connection state that can start and end */ function canvas(ids: (string | undefined)[]) { const calls: { ids: string[]; force: boolean[]; params: unknown }[] = [] let els = ids.map((id) => ({ dataset: id === undefined ? {} : { id } })) + let conn: Conn = { connection: { inProgress: false } } + const listeners: ((s: Conn, prev: Conn) => void)[] = [] const store = { getState: () => ({ + ...conn, domNode: { querySelectorAll: (sel: string) => { expect(sel).toBe('.react-flow__node') @@ -27,8 +33,18 @@ function canvas(ids: (string | undefined)[]) { calls.push({ ids: [...updates.keys()], force: [...updates.values()].map((u) => u.force), params }) }, }), + subscribe: (l: (s: Conn, prev: Conn) => void) => { + listeners.push(l) + return () => listeners.splice(listeners.indexOf(l), 1) + }, } - return { store: store as never, calls, setNodes: (next: string[]) => (els = next.map((id) => ({ dataset: { id } }))) } + /** React Flow's own connection state changes (no node involved) */ + const setInProgress = (inProgress: boolean) => { + const prev = conn + conn = { connection: { inProgress } } + for (const l of [...listeners]) l(conn, prev) + } + return { store: store as never, calls, setInProgress, setNodes: (next: string[]) => (els = next.map((id) => ({ dataset: { id } }))) } } /** animation frames run by hand */ @@ -50,17 +66,15 @@ beforeEach(() => { vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) - __resetFaceBatchForTests() }) +// every test makes its own canvas (its own store), so no state is shared afterEach(() => { - __resetFaceBatchForTests() vi.unstubAllGlobals() }) describe('the face measurement of a drag start / end', () => { - it('a start: the requests of every node in the commit are one measurement, at once, of every node', () => { + it('a start: the requests of every node are one measurement, at once, of every node', () => { const c = canvas(['a', 'b', 'c', 'd']) - // four nodes' layout effects in one commit for (let i = 0; i < 4; i++) measureFacesForDrag(c.store, true) expect(c.calls).toHaveLength(1) expect(c.calls[0]!.ids).toEqual(['a', 'b', 'c', 'd']) @@ -69,6 +83,16 @@ describe('the face measurement of a drag start / end', () => { expect(frames.size, 'nothing deferred at a start').toBe(0) }) + it('the requests of one start may come in separate tasks: still one measurement', async () => { + const c = canvas(['a', 'b', 'c']) + measureFacesForDrag(c.store, true) + await Promise.resolve() + measureFacesForDrag(c.store, true) + await new Promise((r) => setTimeout(r, 0)) + measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(1) + }) + it('an element without a node id is not measured', () => { const c = canvas(['a', undefined, 'b']) measureFacesForDrag(c.store, true) @@ -88,24 +112,35 @@ describe('the face measurement of a drag start / end', () => { expect(c.calls, 'one frame, one measurement').toHaveLength(2) }) - it('a new drag before the end\'s frame: that frame never measures, the start measures at once', () => { + it('an end seen by React Flow and by the nodes is one measurement', () => { + const c = canvas(['a', 'b']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + c.setInProgress(false) + measureFacesForDrag(c.store, false) + measureFacesForDrag(c.store, false) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + }) + + it("a new drag before the end's frame: that frame never measures, the start measures at once", () => { const c = canvas(['a', 'b']) measureFacesForDrag(c.store, true) measureFacesForDrag(c.store, false) expect(frames.size).toBe(1) measureFacesForDrag(c.store, true) measureFacesForDrag(c.store, true) - expect(frames.size, 'the end\'s waiting frame is cancelled').toBe(0) + expect(frames.size, "the end's waiting frame is cancelled").toBe(0) expect(c.calls).toHaveLength(2) runFrames() expect(c.calls, 'no late measurement lands on the new drag').toHaveLength(2) - // that drag's own end is measured as usual measureFacesForDrag(c.store, false) runFrames() expect(c.calls).toHaveLength(3) }) - it('the nodes measured are the canvas\'s when the measurement runs', () => { + it("the nodes measured are the canvas's when the measurement runs", () => { const c = canvas(['a', 'b']) measureFacesForDrag(c.store, true) measureFacesForDrag(c.store, false) @@ -114,9 +149,54 @@ describe('the face measurement of a drag start / end', () => { expect(c.calls[1]!.ids).toEqual(['a', 'b', 'n']) }) + it('a drag whose end no node reported (every node gone mid-drag) is over when React Flow ends it: the next start is measured', () => { + const c = canvas(['a', 'b']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + // the nodes unmount during the drag (its insert undone): no node's layout + // effect sees the drag end; React Flow's connection still ends + c.setNodes([]) + c.setInProgress(false) + runFrames() + expect(c.calls, 'the end frame finds no node to measure').toHaveLength(1) + c.setNodes(['n1', 'n2']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(2) + expect(c.calls[1]!.ids).toEqual(['n1', 'n2']) + }) + + it("every node's own wrapper of one canvas's store is that one canvas: merged", () => { + // useStoreApi() gives each component a new { getState, setState, subscribe } + const c = canvas(['a', 'b', 'c']) + const s = c.store as unknown as { getState: unknown; subscribe: unknown } + const wrappers = [0, 1, 2].map(() => ({ getState: s.getState, subscribe: s.subscribe }) as never) + for (const w of wrappers) measureFacesForDrag(w, true) + expect(c.calls).toHaveLength(1) + for (const w of wrappers) measureFacesForDrag(w, false) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + }) + + it('two canvases are measured apart', () => { + const one = canvas(['a']) + const two = canvas(['x', 'y']) + measureFacesForDrag(one.store, true) + measureFacesForDrag(two.store, true) + expect(one.calls.map((c) => c.ids)).toEqual([['a']]) + expect(two.calls.map((c) => c.ids)).toEqual([['x', 'y']]) + measureFacesForDrag(one.store, false) + expect(frames.size, 'one canvas ending does not cancel nor merge the other').toBe(1) + runFrames() + expect(one.calls).toHaveLength(2) + expect(two.calls).toHaveLength(1) + }) + it('an end with no drag begun measures nothing', () => { const c = canvas(['a']) measureFacesForDrag(c.store, false) + c.setInProgress(false) runFrames() expect(c.calls).toHaveLength(0) }) diff --git a/src/components/nodes/faceBatch.ts b/src/components/nodes/faceBatch.ts index 58977d9a..7f6ebd5d 100644 --- a/src/components/nodes/faceBatch.ts +++ b/src/components/nodes/faceBatch.ts @@ -5,35 +5,46 @@ import type { useStoreApi } from '@xyflow/react' * * Every node mounts its face handles while a connection is dragged (so the * drop can choose a face, `nodes.tsx` `facesOn`) and unmounts the unused ones - * when it ends — every node, in the same commit. A mount or unmount without a - * size change is invisible to React Flow's ResizeObserver, so the handles' - * bounds must be measured again; done one node at a time, that was one React - * Flow store update per node, each run through every store subscriber, and it - * made the start and the end of a drag on a large diagram slow (share note 58: - * the 63-node gacha banner Template). + * when it ends. A mount or unmount without a size change is invisible to React + * Flow's ResizeObserver, so the handles' bounds must be measured again; done + * one node at a time, that was one React Flow store update per node, each run + * through every store subscriber, and it made the start and the end of a drag + * on a large diagram slow (share note 58: the 63-node gacha banner Template). * - * So the first node whose layout effect sees the drag state change measures - * EVERY node in one `updateNodeInternals` call, and the others skip: - * - at the start, at once: React applies the whole commit's DOM changes before - * any layout effect runs, so every node's new handles are in the DOM, - * nothing is painted yet, and React Flow's edges (which reach its store in a - * passive effect) have not caught up — the moment each node measured itself - * in before; - * - at the end, on the next animation frame: the end only removes handles - * (every face was mounted during the drag), and a removal is measured late, - * as a node's own removal is (a store update in this layout effect would run - * React Flow's edge selectors of the previous render, which still look for - * the removed handle — React Flow error 008, share note 53); - * - a start cancels an end's measurement that is still waiting, so it never - * lands on the next drag. + * So the measurement requests of the nodes' layout effects are merged into ONE + * `updateNodeInternals` call holding every node on the canvas: + * - a drag's start: the first request measures every node at once (its new + * handles are in the DOM, nothing is painted yet, and React Flow's edges — + * which reach its store in a passive effect — have not caught up: the moment + * each node measured itself in before); every later request of the same drag + * is answered by it; + * - a drag's end: every node is measured on the next animation frame. The end + * only removes handles (every face was mounted during the drag), and a + * removal is measured late, as a node's own removal is (a store update in + * the layout effect would run React Flow's edge selectors of the previous + * render, which still look for the removed handle — React Flow error 008, + * share note 53). The end is taken from React Flow's own connection state + * (`connection.inProgress` going false) as well as from the nodes, so a drag + * is over even when no node saw it end (every node gone during the drag: its + * insert undone, the node structure switched) and the next drag's start is + * measured; + * - a start cancels an end's frame that has not run yet. A frame is not + * cancelled when the canvas goes away: it runs, reads the store and the DOM + * then, and finds no canvas (React Flow resets its store on unmount, so + * `domNode` is empty) — it measures nothing. * Every other change of a node's mounted faces (a selection, a keyboard focus, * a document change) is measured by that node alone, as before. * - * One canvas exists, so the state is the module's. + * The state is each canvas's. `useStoreApi()` hands every component its own + * wrapper object (`{ getState, setState, subscribe }`, memoized per hook + * call), so the wrapper cannot be the key — keyed by it, every node had its + * own state and nothing was merged (share note 58). The key is the store's own + * `getState`, the same function in every wrapper of one canvas. */ -type RfStore = Pick, 'getState'> +type RfStore = Pick, 'getState' | 'subscribe'> +type Batch = { on: boolean; frame: number } -const state = { on: false, frame: 0 } +const batches = new WeakMap() /** measure every node's handles in ONE React Flow update */ function measureAll(rfStore: RfStore): void { @@ -46,24 +57,40 @@ function measureAll(rfStore: RfStore): void { if (updates.size) updateNodeInternals(updates, { triggerFitView: false }) } -/** a node's layout effect saw the drag state become `connecting` in this commit */ -export function measureFacesForDrag(rfStore: RfStore, connecting: boolean): void { - // the first node of the commit acts; the others find it done - if (connecting === state.on) return - state.on = connecting - cancelAnimationFrame(state.frame) - state.frame = 0 - if (connecting) measureAll(rfStore) - else - state.frame = requestAnimationFrame(() => { - state.frame = 0 - measureAll(rfStore) - }) +/** the drag is over: one measurement of every node on the next frame */ +function endDrag(rfStore: RfStore, b: Batch): void { + if (!b.on) return + b.on = false + cancelAnimationFrame(b.frame) + b.frame = requestAnimationFrame(() => { + b.frame = 0 + measureAll(rfStore) + }) +} + +function batchOf(rfStore: RfStore): Batch { + const known = batches.get(rfStore.getState) + if (known) return known + const b: Batch = { on: false, frame: 0 } + batches.set(rfStore.getState, b) + // the end of a drag as React Flow has it, whether or not a node saw it + rfStore.subscribe((s, prev) => { + if (prev.connection.inProgress && !s.connection.inProgress) endDrag(rfStore, b) + }) + return b } -/** tests only: back to no drag, nothing waiting */ -export function __resetFaceBatchForTests(): void { - if (state.frame) cancelAnimationFrame(state.frame) - state.on = false - state.frame = 0 +/** a node's layout effect saw the drag state become `connecting` */ +export function measureFacesForDrag(rfStore: RfStore, connecting: boolean): void { + const b = batchOf(rfStore) + if (!connecting) { + endDrag(rfStore, b) + return + } + // a request of a drag already measured: answered by that measurement + if (b.on) return + b.on = true + cancelAnimationFrame(b.frame) + b.frame = 0 + measureAll(rfStore) }