Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
119 changes: 86 additions & 33 deletions client/src/pages/widget-editor/model/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): WidgetBlock => ({
Expand Down Expand Up @@ -34,51 +34,104 @@ const widgetWith = (blocks: WidgetBlock[], config: Partial<Widget['config']> = {
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', () => {
Expand Down Expand Up @@ -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);
});
});
119 changes: 85 additions & 34 deletions client/src/pages/widget-editor/model/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockLayout>) : DEFAULT_LAYOUT;
// Blocks saved before layouts existed are stacked by their order.
const value: Partial<BlockLayout> =
input && typeof input === 'object' ? (input as Partial<BlockLayout>) : { 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 {
Expand All @@ -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<string, BlockLayout>();
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). */
Expand All @@ -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
Expand Down
Loading
Loading