From 37606c8c4fa766a91ab03eb6d1d7b064d9c23dbe Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Tue, 29 Sep 2026 00:16:34 +0300 Subject: [PATCH] fix: editor grid placement, block settings validation and public API issues - Resizing keeps the block in place, repacks neighbours with vertical compaction; dropping onto a same-size block swaps them; all moved blocks animate (was: jump to the first free cell in column 0) - Allow empty text blocks, language count as a 3-8 select, length limits on text and username inputs - Stack legacy blocks without a layout by order instead of row 0 - Exact GitHub language bytes via GraphQL when GITHUB_TOKEN is set - Separate, higher rate limit for public SVG images (camo proxy) - Hide userId from the public widget payload - Drop unused width/height from widget create/update --- CHANGELOG.md | 7 ++ .../pages/widget-editor/model/layout.test.ts | 119 +++++++++++++----- .../src/pages/widget-editor/model/layout.ts | 119 +++++++++++++----- .../pages/widget-editor/model/useGridDrag.ts | 99 +++++++-------- .../widget-editor/model/useWidgetEditor.ts | 2 - .../widget-editor/ui/BlockConfigPanel.tsx | 17 ++- client/src/shared/api/widgets.ts | 2 - eslint.config.ts | 5 +- server/src/controllers/widgetController.ts | 9 +- server/src/routes/widgets.ts | 14 ++- server/src/services/statsService.ts | 73 ++++++++++- server/src/services/widgetService.ts | 6 - server/src/statsService.test.ts | 104 +++++++++++++++ server/src/widgets.test.ts | 52 ++++++++ server/src/widgets/registry.ts | 3 +- 15 files changed, 485 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130bbc2..49fa2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes to this project are documented here. ### Fixed +- Fixed editor blocks jumping to another column or far down the grid when resized: the edited block stays in place, neighbours move down and gaps close; dropping a block onto one of the same size swaps them, and every moved block animates. +- Fixed saving failing with a raw validation error after clearing a text block or entering an out-of-range language count; the count is now a 3–8 select and text/username fields have length limits. +- Fixed legacy blocks without a stored layout all landing on the first row in the editor. +- Computed GitHub language shares from real per-language byte counts across all public repositories (when `GITHUB_TOKEN` is set) instead of each repository's total size on its primary language. +- Gave public SVG images their own, much higher rate limit, since README views arrive through GitHub's image proxy from a few shared IPs. +- Stopped exposing the widget owner's user id in the public widget API. +- Removed unused `width`/`height` fields from widget create/update requests; size is always derived from the layout. - Fixed smaller block padding and larger fonts in SVG exports compared to the editor and iframe. - Fixed widget size differences between the editor, public page, iframe and SVG export caused by viewport-relative padding. - Removed the drop shadow that tinted the transparent corners of exported SVG and iframe widgets. diff --git a/client/src/pages/widget-editor/model/layout.test.ts b/client/src/pages/widget-editor/model/layout.test.ts index 9a1dba6..087690e 100644 --- a/client/src/pages/widget-editor/model/layout.test.ts +++ b/client/src/pages/widget-editor/model/layout.test.ts @@ -1,10 +1,10 @@ import type { Widget, WidgetBlock } from '@/entities/widget/model'; import { - canPlaceBlock, dragGridRows, - findPlacement, + getLayout, + moveBlock, normalizeWidget, - withBlockLayout, + placeBlock, } from '@/pages/widget-editor/model/layout'; const block = (id: string, layout: unknown, extra: Partial = {}): WidgetBlock => ({ @@ -34,51 +34,104 @@ const widgetWith = (blocks: WidgetBlock[], config: Partial = { blocks, }); -describe('canPlaceBlock', () => { - const widget = widgetWith([ +const layouts = (widget: Widget) => + Object.fromEntries(widget.blocks.map((item) => [item.id, getLayout(item)])); + +describe('placeBlock', () => { + // A B + // C D + const grid = widgetWith([ block('a', { x: 0, y: 0, width: 1, height: 1 }), - block('b', { x: 1, y: 0, width: 1, height: 2 }), + block('b', { x: 1, y: 0, width: 1, height: 1 }), + block('c', { x: 0, y: 1, width: 1, height: 1 }), + block('d', { x: 1, y: 1, width: 1, height: 1 }), ]); - it('rejects overlaps and out-of-grid layouts', () => { - expect(canPlaceBlock(widget, 'a', { x: 1, y: 1, width: 1, height: 1 })).toBe(false); - expect(canPlaceBlock(widget, 'a', { x: 1, y: 2, width: 2, height: 1 })).toBe(false); + it('keeps a resized block in place and pushes the blocks below it down', () => { + expect(layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 1, height: 2 }))).toEqual({ + a: { x: 0, y: 0, width: 1, height: 1 }, + b: { x: 1, y: 0, width: 1, height: 2 }, + c: { x: 0, y: 1, width: 1, height: 1 }, + d: { x: 1, y: 2, width: 1, height: 1 }, + }); }); - it('ignores the block being moved', () => { - expect(canPlaceBlock(widget, 'b', { x: 1, y: 1, width: 1, height: 1 })).toBe(true); - expect(canPlaceBlock(widget, 'a', { x: 0, y: 1, width: 1, height: 2 })).toBe(true); + it('cascades pushes when a block grows to the full width', () => { + expect(layouts(placeBlock(grid, 'a', { x: 0, y: 0, width: 2, height: 1 }))).toEqual({ + a: { x: 0, y: 0, width: 2, height: 1 }, + b: { x: 1, y: 1, width: 1, height: 1 }, + c: { x: 0, y: 1, width: 1, height: 1 }, + d: { x: 1, y: 2, width: 1, height: 1 }, + }); }); -}); -describe('findPlacement', () => { - it('keeps the preferred cell when it is free', () => { - const widget = widgetWith([block('a', { x: 0, y: 0, width: 1, height: 1 })]); - expect(findPlacement(widget, 'a', 2, 1, 0, 0)).toEqual({ x: 0, y: 0, width: 2, height: 1 }); + it('shifts a block in the right column left when it grows wider than the grid allows', () => { + const next = layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 2, height: 1 })); + expect(next.b).toEqual({ x: 0, y: 0, width: 2, height: 1 }); + expect(next.a).toEqual({ x: 0, y: 1, width: 1, height: 1 }); }); - it('moves a grown block below the blocks it would cover', () => { - const widget = widgetWith([ - block('a', { x: 0, y: 0, width: 1, height: 1 }), - block('b', { x: 1, y: 0, width: 1, height: 1 }), - ]); - expect(findPlacement(widget, 'a', 2, 1, 0, 0)).toEqual({ x: 0, y: 1, width: 2, height: 1 }); + it('closes the gap when a block shrinks back', () => { + const grown = placeBlock(grid, 'b', { x: 1, y: 0, width: 1, height: 2 }); + expect(layouts(placeBlock(grown, 'b', { x: 1, y: 0, width: 1, height: 1 }))).toEqual( + layouts(grid), + ); }); -}); -it('offers one spare drop row below the lowest block', () => { - expect(dragGridRows(widgetWith([]))).toBe(2); - expect(dragGridRows(widgetWith([block('a', { x: 0, y: 1, width: 1, height: 2 })]))).toBe(4); + it('swaps places when a block is dropped onto an occupied cell below it', () => { + expect(layouts(placeBlock(grid, 'a', { x: 0, y: 1, width: 1, height: 1 }))).toMatchObject({ + a: { x: 0, y: 1 }, + c: { x: 0, y: 0 }, + }); + }); + + it('swaps places when a block is dropped onto an occupied cell above it', () => { + expect(layouts(placeBlock(grid, 'd', { x: 1, y: 0, width: 1, height: 1 }))).toMatchObject({ + d: { x: 1, y: 0 }, + b: { x: 1, y: 1 }, + }); + }); + + it('lifts a block dropped into an empty row below the others', () => { + expect(layouts(placeBlock(grid, 'a', { x: 0, y: 2, width: 1, height: 1 }))).toMatchObject({ + c: { x: 0, y: 0 }, + a: { x: 0, y: 1 }, + }); + }); + + it('leaves untouched blocks as the same objects', () => { + const next = placeBlock(grid, 'b', { x: 1, y: 0, width: 1, height: 2 }); + expect(next.blocks[0]).toBe(grid.blocks[0]); + expect(next.blocks[2]).toBe(grid.blocks[2]); + }); }); -it('updates one block layout without touching the others', () => { - const widget = widgetWith([ +describe('moveBlock', () => { + const grid = widgetWith([ block('a', { x: 0, y: 0, width: 1, height: 1 }), block('b', { x: 1, y: 0, width: 1, height: 1 }), + block('c', { x: 0, y: 1, width: 1, height: 2 }), ]); - const next = withBlockLayout(widget, 'b', { x: 0, y: 1, width: 2, height: 1 }); - expect(next.blocks[0]).toBe(widget.blocks[0]); - expect(next.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 2, height: 1 }); + + it('swaps two blocks of the same size', () => { + expect(layouts(moveBlock(grid, 'a', { x: 1, y: 0 }))).toMatchObject({ + a: { x: 1, y: 0 }, + b: { x: 0, y: 0 }, + c: { x: 0, y: 1 }, + }); + }); + + it('pushes blocks of a different size instead of swapping', () => { + expect(layouts(moveBlock(grid, 'b', { x: 0, y: 1 }))).toMatchObject({ + b: { x: 0, y: 1 }, + c: { x: 0, y: 2 }, + }); + }); +}); + +it('offers one spare drop row below the lowest block', () => { + expect(dragGridRows(widgetWith([]))).toBe(2); + expect(dragGridRows(widgetWith([block('a', { x: 0, y: 1, width: 1, height: 2 })]))).toBe(4); }); describe('normalizeWidget', () => { @@ -119,7 +172,7 @@ describe('normalizeWidget', () => { username: 'octocat', layout: { x: 1, y: 0, width: 1, height: 2 }, }); - expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 1, height: 1 }); expect(widget.height).toBe(600); }); }); diff --git a/client/src/pages/widget-editor/model/layout.ts b/client/src/pages/widget-editor/model/layout.ts index f2e5fbb..1c3ed39 100644 --- a/client/src/pages/widget-editor/model/layout.ts +++ b/client/src/pages/widget-editor/model/layout.ts @@ -38,8 +38,9 @@ export const getLayout = (block: WidgetBlock): BlockLayout => { /** Layout read from possibly legacy block config, clamped into the grid. */ const layoutFromBlock = (block: WidgetBlock, index: number): BlockLayout => { const input = block.config.layout; - const value = - input && typeof input === 'object' ? (input as Partial) : DEFAULT_LAYOUT; + // Blocks saved before layouts existed are stacked by their order. + const value: Partial = + input && typeof input === 'object' ? (input as Partial) : { y: index }; const x = clamp(typeof value.x === 'number' ? value.x : 0, 0, MAX_COLUMNS - 1); const width = clamp(typeof value.width === 'number' ? value.width : 1, 1, MAX_COLUMNS - x); return { @@ -62,31 +63,89 @@ const layoutsOverlap = (left: BlockLayout, right: BlockLayout) => left.y < right.y + right.height && left.y + left.height > right.y; -export const canPlaceBlock = (widget: Widget, blockId: string, nextLayout: BlockLayout) => - nextLayout.x >= 0 && - nextLayout.x + nextLayout.width <= MAX_COLUMNS && - !widget.blocks.some( - (block) => block.id !== blockId && layoutsOverlap(nextLayout, getLayout(block)), - ); +// Highest row where the layout fits without overlapping `obstacles`. +const firstFreeRow = (layout: BlockLayout, obstacles: BlockLayout[]) => { + for (let y = 0; y <= MAX_ROW; y += 1) { + const candidate = { ...layout, y }; + if (!obstacles.some((item) => layoutsOverlap(item, candidate))) return y; + } + return layout.y; +}; -/** First free spot for a block of the given size: the preferred cell, then scanning down column 0. */ -export const findPlacement = ( - widget: Widget, - blockId: string, - width: number, - height: number, - preferredX: number, - preferredY: number, -) => { - const startY = clamp(preferredY, 0, MAX_ROW); - const startX = clamp(preferredX, 0, MAX_COLUMNS - width); - const candidates = [ - { x: startX, y: startY }, - ...Array.from({ length: MAX_ROW + 1 - startY }, (_, index) => ({ x: 0, y: startY + index })), - ]; - return candidates - .map(({ x, y }) => ({ x, y, width, height })) - .find((layout) => canPlaceBlock(widget, blockId, layout)); +/** + * Puts a block at `layout` (clamped into the grid) and repacks the rest with vertical + * compaction: other blocks, top to bottom, take the highest free row around the edited block, + * then the edited block rises into any space left above it. Resizing never throws a block to + * another column, dropping onto an occupied cell swaps places, and shrinking closes the gap. + */ +export const placeBlock = (widget: Widget, blockId: string, layout: BlockLayout): Widget => { + const width = clamp(layout.width, 1, MAX_COLUMNS); + const target = { + x: clamp(layout.x, 0, MAX_COLUMNS - width), + y: clamp(layout.y, 0, MAX_ROW), + width, + height: clamp(layout.height, 1, 2), + }; + const others = widget.blocks + .filter((block) => block.id !== blockId) + .map((block) => ({ id: block.id, layout: { ...getLayout(block) } })) + .sort((left, right) => left.layout.y - right.layout.y || left.layout.x - right.layout.x); + + const placed = new Map(); + for (const other of others) { + other.layout.y = firstFreeRow(other.layout, [target, ...placed.values()]); + placed.set(other.id, other.layout); + } + target.y = firstFreeRow(target, [...placed.values()]); + placed.set(blockId, target); + + return { + ...widget, + config: { ...widget.config, grid: { columns: MAX_COLUMNS } }, + blocks: widget.blocks.map((block) => { + const next = placed.get(block.id)!; + const current = getLayout(block); + const unchanged = + current.x === next.x && + current.y === next.y && + current.width === next.width && + current.height === next.height; + return unchanged ? block : { ...block, config: { ...block.config, layout: next } }; + }), + }; +}; + +/** + * Drops a block with its top-left at `cell`. Dropping exactly onto a block of the same size + * swaps the two; anything else goes through `placeBlock`. + */ +export const moveBlock = (widget: Widget, blockId: string, cell: { x: number; y: number }) => { + const moving = widget.blocks.find((block) => block.id === blockId); + if (!moving) return widget; + const from = getLayout(moving); + const to = { ...from, ...cell }; + const occupant = widget.blocks.find((block) => { + const layout = getLayout(block); + return ( + block.id !== blockId && + layout.x === to.x && + layout.y === to.y && + layout.width === from.width && + layout.height === from.height + ); + }); + if (!occupant) return placeBlock(widget, blockId, to); + return { + ...widget, + config: { ...widget.config, grid: { columns: MAX_COLUMNS } }, + blocks: widget.blocks.map((block) => + block.id === blockId + ? { ...block, config: { ...block.config, layout: to } } + : block.id === occupant.id + ? { ...block, config: { ...block.config, layout: from } } + : block, + ), + }; }; /** Rows shown while dragging: occupied rows plus one spare drop row (at least two). */ @@ -105,14 +164,6 @@ export const dragGridRows = (widget: Widget) => export const occupiedRows = (widget: Widget) => Math.max(1, ...widget.blocks.map((block) => getLayout(block).y + getLayout(block).height)); -export const withBlockLayout = (widget: Widget, blockId: string, layout: BlockLayout): Widget => ({ - ...widget, - config: { ...widget.config, grid: { columns: MAX_COLUMNS } }, - blocks: widget.blocks.map((block) => - block.id === blockId ? { ...block, config: { ...block.config, layout } } : block, - ), -}); - /** * Brings a widget from the API or local cache into the shape the editor works with: every block * has a clamped layout and its own username (legacy widgets kept it on the widget), sizes are diff --git a/client/src/pages/widget-editor/model/useGridDrag.ts b/client/src/pages/widget-editor/model/useGridDrag.ts index 2b68ef3..f178cab 100644 --- a/client/src/pages/widget-editor/model/useGridDrag.ts +++ b/client/src/pages/widget-editor/model/useGridDrag.ts @@ -5,11 +5,10 @@ import type { Widget } from '@/entities/widget/model'; import { GRID_GAP } from '@shared/widget/geometry'; import { MAX_COLUMNS, - canPlaceBlock, clamp, - findPlacement, getLayout, - withBlockLayout, + moveBlock, + placeBlock, } from '@/pages/widget-editor/model/layout'; type Cell = { x: number; y: number }; @@ -78,63 +77,61 @@ export const useGridDrag = ({ setDropCell(cell); }; + // FLIP: every block that moves (the edited one and those pushed aside) animates from its old + // rect to the new one instead of jumping. + const animateLayoutChange = (applyChange: () => void) => { + const elements = Array.from( + gridRef.current?.querySelectorAll('[data-block-id]') ?? [], + ); + const before = new Map(elements.map((element) => [element, element.getBoundingClientRect()])); + flushSync(applyChange); + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + requestAnimationFrame(() => { + for (const [element, first] of before) { + if (!element.isConnected || !element.animate) continue; + const last = element.getBoundingClientRect(); + if (!last.width || !last.height) continue; + const moved = + first.left !== last.left || + first.top !== last.top || + first.width !== last.width || + first.height !== last.height; + if (!moved) continue; + // The canvas may be scaled down; translate in the block's own (unscaled) pixels. + const scale = last.width / element.offsetWidth || 1; + element.animate( + [ + { + transform: `translate(${(first.left - last.left) / scale}px, ${(first.top - last.top) / scale}px) scale(${first.width / last.width}, ${first.height / last.height})`, + transformOrigin: 'top left', + }, + { transform: 'translate(0, 0) scale(1, 1)', transformOrigin: 'top left' }, + ], + { duration: 260, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + } + }); + }; + const onPointerUp = (event: PointerEvent) => { const drag = dragRef.current; const current = widgetRef.current; if (!drag || drag.pointerId !== event.pointerId || !current) return; - const block = current.blocks.find((item) => item.id === drag.blockId); - if (block) { - const nextLayout = { ...getLayout(block), ...drag.preview }; - if (canPlaceBlock(current, drag.blockId, nextLayout)) { - updateLocalWidget((widget) => withBlockLayout(widget, drag.blockId, nextLayout)); - } - } event.currentTarget.releasePointerCapture(event.pointerId); + const block = current.blocks.find((item) => item.id === drag.blockId); + const layout = block ? getLayout(block) : null; endDrag(); - }; - - // FLIP animation from the old block rect to the new one after a size change. - const animateResize = (blockId: string, applyChange: () => void) => { - const blockElement = gridRef.current?.querySelector( - `[data-block-id="${CSS.escape(blockId)}"]`, - ); - const first = blockElement?.getBoundingClientRect(); - flushSync(applyChange); - if ( - !blockElement || - !first || - !blockElement.animate || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { - return; - } - requestAnimationFrame(() => { - const last = blockElement.getBoundingClientRect(); - if (!last.width || !last.height) return; - // The canvas may be scaled down; translate in the block's own (unscaled) pixels. - const scale = last.width / blockElement.offsetWidth || 1; - blockElement.animate( - [ - { - transform: `translate(${(first.left - last.left) / scale}px, ${(first.top - last.top) / scale}px) scale(${first.width / last.width}, ${first.height / last.height})`, - transformOrigin: 'top left', - }, - { transform: 'translate(0, 0) scale(1, 1)', transformOrigin: 'top left' }, - ], - { duration: 260, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, - ); - }); + if (!layout || (layout.x === drag.preview.x && layout.y === drag.preview.y)) return; + const cell = drag.preview; + animateLayoutChange(() => updateLocalWidget((widget) => moveBlock(widget, drag.blockId, cell))); }; const resizeBlock = (blockId: string, width: number, height: number) => { - const current = widgetRef.current; - const block = current?.blocks.find((item) => item.id === blockId); - if (!current || !block) return; - const { x, y } = getLayout(block); - const nextLayout = findPlacement(current, blockId, width, height, x, y); - if (!nextLayout) return; - animateResize(blockId, () => - updateLocalWidget((widget) => withBlockLayout(widget, blockId, nextLayout)), + const block = widgetRef.current?.blocks.find((item) => item.id === blockId); + if (!block) return; + const nextLayout = { ...getLayout(block), width, height }; + animateLayoutChange(() => + updateLocalWidget((widget) => placeBlock(widget, blockId, nextLayout)), ); }; diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.ts b/client/src/pages/widget-editor/model/useWidgetEditor.ts index 83e8f45..e0e4cf1 100644 --- a/client/src/pages/widget-editor/model/useWidgetEditor.ts +++ b/client/src/pages/widget-editor/model/useWidgetEditor.ts @@ -128,8 +128,6 @@ export const useWidgetEditor = ({ await Promise.all(current.blocks.map((block) => updateBlock(block.id, block.config))); const saved = await updateWidget(current.id, { title: current.title, - width: current.width, - height: current.height, public: publish || current.public, config: current.config, }); diff --git a/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx index b417578..c7fcaff 100644 --- a/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx +++ b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx @@ -5,6 +5,9 @@ import { sourceForBlock } from '@/pages/widget-editor/model/layout'; import { messages, type Locale } from '@/shared/locale/content'; import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; +// Mirrors the server schema for `github-langs.limit` (3–8). +const LANGUAGE_LIMITS = { default: 5, options: [3, 4, 5, 6, 7, 8] } as const; + export const BlockConfigPanel = ({ block, locale, @@ -42,6 +45,7 @@ export const BlockConfigPanel = ({ {source === 'github' ? t.githubUsername : t.leetcodeUsername} onChange({ username: value })} @@ -54,6 +58,7 @@ export const BlockConfigPanel = ({ {locale === 'ru' ? 'Текст' : 'Text'}