From 544269687a53504e92d1d8720861f721df0e4d72 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Sat, 3 Oct 2026 17:51:22 +0300 Subject: [PATCH 1/2] refactor(widgets): render widgets with one shared SVG renderer - Split server/src/shared/widget into svgPrimitives, BlockContent, canvasParts and the WidgetCanvas export composition - Client WidgetSurface places the shared CanvasBackground and a BlockShell per block (separate SVGs positioned by canvasBoxes); WidgetCanvas and the skeleton are thin wrappers; the HTML canvas and its CSS are removed - Editor blocks are DOM overlays (drag, size, remove, selection) around the shared block SVG; drop cells sit in a layer over the grid area - Loading state is an SVG skeleton shared with the export --- .claude/skills/add-block-type/SKILL.md | 17 +- .claude/skills/widget-render-parity/SKILL.md | 55 +- AGENTS.md | 6 +- CHANGELOG.md | 1 + client/src/entities/widget/index.ts | 8 +- .../entities/widget/lib/canvasStyle.test.ts | 26 - client/src/entities/widget/lib/canvasStyle.ts | 103 -- .../widget/ui/WidgetCanvas.module.css | 455 -------- .../entities/widget/ui/WidgetCanvas.test.tsx | 21 +- .../src/entities/widget/ui/WidgetCanvas.tsx | 526 +-------- .../widget/ui/WidgetSurface.module.css | 20 + .../src/entities/widget/ui/WidgetSurface.tsx | 126 +++ .../pages/widget-editor/ui/EditorBlock.tsx | 24 +- .../pages/widget-editor/ui/EditorCanvas.tsx | 122 +- .../ui/WidgetEditorPage.module.css | 51 +- server/src/shared/widget/BlockContent.tsx | 569 ++++++++++ server/src/shared/widget/WidgetCanvas.tsx | 1003 +---------------- server/src/shared/widget/canvasParts.tsx | 179 +++ server/src/shared/widget/svgPrimitives.tsx | 314 ++++++ server/src/shared/widget/types.ts | 16 + 20 files changed, 1464 insertions(+), 2178 deletions(-) delete mode 100644 client/src/entities/widget/lib/canvasStyle.test.ts delete mode 100644 client/src/entities/widget/lib/canvasStyle.ts delete mode 100644 client/src/entities/widget/ui/WidgetCanvas.module.css create mode 100644 client/src/entities/widget/ui/WidgetSurface.module.css create mode 100644 client/src/entities/widget/ui/WidgetSurface.tsx create mode 100644 server/src/shared/widget/BlockContent.tsx create mode 100644 server/src/shared/widget/canvasParts.tsx create mode 100644 server/src/shared/widget/svgPrimitives.tsx create mode 100644 server/src/shared/widget/types.ts diff --git a/.claude/skills/add-block-type/SKILL.md b/.claude/skills/add-block-type/SKILL.md index 138b12c..d8f85a3 100644 --- a/.claude/skills/add-block-type/SKILL.md +++ b/.claude/skills/add-block-type/SKILL.md @@ -1,13 +1,14 @@ --- name: add-block-type -description: Checklist for adding a new widget block type to WidgeCode (e.g. GitHub commits, pull requests, GitHub status, new data source) across server registry, stats fetching, SVG export, client editor/canvas, locales, and tests. Use whenever a new block, block option, or data source is being added. +description: Checklist for adding a new widget block type to WidgeCode (e.g. GitHub commits, pull requests, GitHub status, new data source) across server registry, stats fetching, the shared SVG renderer, client editor, locales, and tests. Use whenever a new block, block option, or data source is being added. argument-hint: '[block-type-id]' --- # Add a block type A block type exists in several places that are not linked by types. Miss one and the block will -validate on the server but render blank in the editor, or render in the editor but not in the SVG export. +validate on the server but render as a blank box. Rendering itself is written once: the browser and +the `/image.svg` export use the same SVG components. ## 1. Server registry — `server/src/widgets/registry.ts` (source of truth) @@ -24,18 +25,18 @@ validate on the server but render blank in the editor, or render in the editor b - Branch on the new type in `getBlockData`. Throw `AppError` for "not found"; `renderWidgetStats` turns errors into `block.error`. - Mind the budget: public SVG/iframe views call this per request (15 min cache per instance). Prefer one API call per block. -## 3. SVG export — `server/src/shared/widget/WidgetCanvas.tsx` +## 3. Rendering — `server/src/shared/widget/BlockContent.tsx` (used everywhere) - Add a block renderer in `BlockContent` next to the existing ones. Use the `frame` (content width/height + `blockTypography`), `baseline()`, `fitText`, `StatsRow`/`TitleRow` helpers — no hard-coded font sizes. -- Put new labels in `server/src/shared/widget/theme.ts` (`widgetLabels`, ru + en) so both renderers share them. -- Handle loading/`error`/empty data states like the other blocks. -- Any image must be a data URI (see `buildAvatarDataUris` in `widgetController.ts`) — external `href`s don't load when the SVG is used as `` on GitHub. +- Primitives live in `svgPrimitives.tsx`; the block shell and canvas pieces in `canvasParts.tsx`. +- Put new labels in `server/src/shared/widget/theme.ts` (`widgetLabels`, ru + en). +- Loading (`BlockSkeleton`), `error` and missing-username states are handled before the type switch; make sure the new block doesn't bypass them. +- Images: the export needs data URIs (see `buildAvatarDataUris` in `widgetController.ts`) because external `href`s don't load when the SVG is used as `` on GitHub; in the browser the URL is used directly. ## 4. Client — `client/src/entities/widget/` - `model/types.ts`: extend `BlockType` and the rendered data types. - `model/registry.ts`: labels + ru/en descriptions, presets mirror. -- `ui/WidgetCanvas.tsx` + `.module.css`: add `sampleData[type]` (shown before live data) and the HTML renderer, mirroring the SVG element order and using only the `--block-*` custom properties — see the `widget-render-parity` skill. - `pages/widget-editor/ui/WidgetEditorPage.tsx`: block palette entry and settings controls for each option. - `shared/locale/content.ts`: all new strings in both `ru` and `en`. @@ -43,7 +44,7 @@ validate on the server but render blank in the editor, or render in the editor b - `server/src/widgets.test.ts`: creating/updating a widget with the new block validates; bad config → 400. - `server/src/statsService.test.ts`: mock `fetch`, assert the mapped shape and cache key. -- `server/src/services/widgetCanvas.test.ts`: SVG renders the block without throwing and contains expected text. +- `server/src/services/widgetCanvas.test.ts`: SVG renders the block without throwing and contains expected text; `client/src/entities/widget/ui/WidgetCanvas.test.tsx` for browser-only states if any. - `CHANGELOG.md` → `[Unreleased] / Added`. Update `README*.md` feature list if it's user-visible. - Finish with the `verify` skill. diff --git a/.claude/skills/widget-render-parity/SKILL.md b/.claude/skills/widget-render-parity/SKILL.md index 9edca0e..0dc99ca 100644 --- a/.claude/skills/widget-render-parity/SKILL.md +++ b/.claude/skills/widget-render-parity/SKILL.md @@ -1,41 +1,44 @@ --- name: widget-render-parity -description: Diagnose and fix size, padding, gap, scale, font, or background differences between the widget in the editor, the public page/iframe, and the exported SVG image, and keep them in sync when changing widget geometry or block visuals. Use for bugs like "widget looks different in SVG", "blocks are smaller in export", "preview scale is off", or any change to block layout/typography. +description: Explains how WidgeCode renders widgets (one shared SVG renderer for the editor, public page, iframe and /image.svg export) and how to debug size, layout or text differences. Use for bugs like "widget looks different in SVG", "blocks are cut off", "preview scale is off", or when changing widget geometry or block visuals. --- -# Widget render parity +# Widget rendering -## Architecture +There is one renderer. Everything that draws a widget uses the same SVG React components from +`server/src/shared/widget/` (imported on the client as `@shared/widget/*`). -| Piece | File | Role | -| ------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Geometry | `server/src/shared/widget/geometry.ts` | Width 600, `canvasPadding` (24), `GRID_GAP` 18, square `cellSize` (267), `BLOCK_PADDING` 20, radii, line heights, `widgetDimensions`, `blockBox`, `blockTypography` | -| Theme | `server/src/shared/widget/theme.ts` | Palettes, language/difficulty colors, ru/en block labels, `formatStatValue` | -| HTML renderer | `client/src/entities/widget/ui/WidgetCanvas.tsx` + `.module.css` | Editor blocks, `/w/:slug`, iframe embed | -| HTML bridge | `client/src/entities/widget/lib/canvasStyle.ts` | Turns geometry into CSS custom properties (`canvasStyleVars`, `blockStyleVars`, `blockMetrics`) | -| Scaling | `client/src/entities/widget/ui/ScaledWidgetFrame.tsx` | Renders the canvas at stored size and scales it to fit (editor, public page) | -| SVG renderer | `server/src/shared/widget/WidgetCanvas.tsx` | `/api/public/widgets/:slug/image.svg` | -| Stored size | `widgetService.widgetDimensions` → shared `widgetDimensions` | Drives iframe size and SVG viewBox | +| Piece | File | Role | +| ------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Geometry | `geometry.ts` | Width 600, padding, gap, cell size, block padding, radii, line heights, `widgetDimensions`, `blockBox`, `blockTypography`, `estimateTextWidth`, heatmap sizing | +| Theme | `theme.ts` | Palettes, colors, ru/en block labels, `formatStatValue` | +| Primitives | `svgPrimitives.tsx` | `SvgText`, `MultilineText`, `TitleRow`, `StatsRow`, `fitText`, `linesOf`, `baseline`, `layoutOf` | +| Block content | `BlockContent.tsx` | Per-type drawing inside a block's content box, plus skeleton / error / preview states | +| Canvas parts | `canvasParts.tsx` | `CanvasBackground`, `BlockShell`, `CanvasEmptyState`, `canvasBoxes`, `canvasTokens` | +| Export | `WidgetCanvas.tsx` | Composes the parts into one standalone `` for `/api/public/widgets/:slug/image.svg` | +| Browser | `client/src/entities/widget/ui/WidgetSurface.tsx` | Same parts, but the background and each block are separate ``s placed with `canvasBoxes`, so the editor can attach DOM controls (drag handle, size, remove) to each block | +| Scaling | `client/src/entities/widget/ui/ScaledWidgetFrame.tsx` | Renders at stored size and scales to fit (editor, public page) | Rules: -- A number that affects layout belongs in `geometry.ts` / `theme.ts`, never hard-coded in one renderer. -- The HTML canvas CSS must only use the custom properties — no `vw`, `cqi`, `%` font sizes or media queries. Responsiveness comes from `ScaledWidgetFrame`, not reflow. -- The SVG has no text layout: text widths are estimated (`fitText`, `linesOf`, glyph-width constants). HTML uses `nowrap` + ellipsis on the same elements so both truncate in the same places. `formatStatValue` picks full vs compact numbers from the same estimate in both renderers. -- The SVG uses Inter metrics for baselines (`baseline()`); the HTML canvas sets the same font stack (`WIDGET_FONT_FAMILY`) and `--block-line-height`. +- Layout numbers belong in `geometry.ts` / `theme.ts`. +- SVG has no text layout: widths come from `estimateTextWidth` (per-character-class widths + calibrated to the font). Truncate with `fitText`, wrap with `linesOf`; never assume CSS will do it. +- SVG ids (gradients, clip paths) must be prefixed per instance (`idPrefix`) — several widgets can + be on one page in the browser. +- The export inlines images as data URIs; the browser uses URLs. -## Changing block visuals +## Debugging differences -1. Change geometry/typography in `geometry.ts` if a size changes; expose new values in `blockStyleVars` and consume them in the CSS module. -2. Update the HTML markup/CSS and the SVG block renderer in the same change, keeping the element order and gaps identical (the SVG comments name the CSS classes they mirror). -3. Extend `server/src/shared/widget/geometry.test.ts` or `server/src/services/widgetCanvas.test.ts` with the new numbers. +Because the code is shared, differences between the editor, iframe and export come from inputs, +not drawing: different `height`/`rows` (the editor adds a spare row while dragging), different +`renderedBlocks` (editor previews vs public data), locale (`?locale=` for the export), or fonts +installed on the viewer's machine (affects real text width vs the estimate). ## Visual check 1. `docker compose -p widgecode up -d`, then start the `api` and `client` launch configs (`.claude/launch.json`). -2. Create/publish a test widget via the API (local test account), then open side by side at the same zoom: - - `/w/?embed=1` (iframe content, real size) - - `http://localhost:4000/api/public/widgets//image.svg` (add `?locale=ru` to match a Russian UI) - - `/w/` and the editor `/widgets/` (scaled) -3. Known intentional difference: the SVG locale comes from `?locale=` rather than the app setting. -4. Finish with the `verify` skill. +2. Create/publish a test widget via the API (local test account), then compare at the same zoom: + `/w/?embed=1`, `http://localhost:4000/api/public/widgets//image.svg?locale=ru`, + `/w/` and the editor `/widgets/`. +3. Finish with the `verify` skill. diff --git a/AGENTS.md b/AGENTS.md index d805c9a..c7b06c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ server/src/ lib/ env, jwt, prisma singleton, AppError server/prisma/ schema.prisma + migrations client/src/ Feature-Sliced Design: app → pages → widgets → features → entities → shared - entities/widget/ widget types, presets, palettes (mirror server registry) and the HTML/CSS WidgetCanvas used in editor/iframe + entities/widget/ widget types, presets, palettes (mirror server registry); WidgetSurface/WidgetCanvas render the shared SVG parts in the browser pages/widget-editor/ the grid editor: model/ (layout rules, normalizeWidget, useWidgetEditor = load/autosave/save race handling, useGridDrag, useBlockPreviews) + ui/ components shared/api/ fetch client with access-token + single-flight refresh @@ -73,8 +73,8 @@ Server imports must use `.js` extensions (NodeNext). ## Invariants and gotchas -- **One geometry, two renderers.** All widget numbers (width 600, padding, gap, square cells, block padding, typography scale, palettes, labels, stat formatting) live in `server/src/shared/widget/{geometry,theme}.ts`. The HTML canvas (`client/src/entities/widget/ui/WidgetCanvas.tsx`, used by the editor, public page and iframe) gets them as CSS custom properties via `entities/widget/lib/canvasStyle.ts`; the SVG export (`server/src/shared/widget/WidgetCanvas.tsx`) uses them directly; `widgetService` stores sizes from `widgetDimensions`. The HTML canvas always renders at the stored size and is scaled by `ScaledWidgetFrame` — never add `vw`/`cqi`/`%`-based sizes to it. See the `widget-render-parity` skill. -- **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Adding a block type touches both plus `statsService` and both canvases — use the `add-block-type` skill. +- **One renderer.** Every surface (editor, public page, iframe, `/image.svg`) draws widgets with the same SVG components in `server/src/shared/widget/` (`BlockContent`, `canvasParts`, `svgPrimitives`), using numbers from `geometry.ts`/`theme.ts`. The export composes them into one `` (`WidgetCanvas.tsx`); the browser places the background and each block as separate ``s (`client/src/entities/widget/ui/WidgetSurface.tsx`) so the editor can attach controls, and scales the whole thing with `ScaledWidgetFrame`. Text widths are estimated (`estimateTextWidth`), so truncate/wrap in SVG code, not CSS. See the `widget-render-parity` skill. +- **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Rendering is written once in `shared/widget/BlockContent.tsx`. Use the `add-block-type` skill. - Limits: `MAX_WIDGET_BLOCKS = 5` in the registry; `MAX_GRID_COLUMNS = 2` and block height ≤ 2 in shared `geometry.ts` (re-exported by the registry). Layout validation (bounds + no overlap) runs in `widgetService.validateLayouts`. - Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored. - GitHub data: profile, repositories and languages use REST (a token only raises limits; languages switch to exact GraphQL byte counts with a token). Activity, pull request and status blocks use GraphQL only and show an error without `GITHUB_TOKEN`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f46d589..bd3aed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project are documented here. ### Changed +- The editor, public page and iframe now render widgets with the same SVG components as the image export (one renderer instead of an HTML and an SVG copy), so every surface shows exactly the same widget. - SVG text truncation and stat number formatting now use a per-character width estimate calibrated to the widget font (Cyrillic, bold and `%` are wider), so the SVG export cuts text in the same places as the HTML widget. - Split the 1,200-line widget editor into layout rules, state/autosave, drag and preview hooks, and focused UI components, and shared the embed snippet builder between the gallery and the editor. - Added client tests for widget rendering, scaling, editor layout rules and autosave, the API client token refresh, and the auth store. diff --git a/client/src/entities/widget/index.ts b/client/src/entities/widget/index.ts index 873d3ad..b505ec3 100644 --- a/client/src/entities/widget/index.ts +++ b/client/src/entities/widget/index.ts @@ -3,11 +3,7 @@ export { WidgetCardSkeleton, type WidgetCardLabels, } from '@/entities/widget/ui/WidgetCard'; -export { blockStyleVars, canvasStyleVars, getBlockLayout } from '@/entities/widget/lib/canvasStyle'; -export { - WidgetBlockContent, - WidgetCanvas, - WidgetCanvasSkeleton, -} from '@/entities/widget/ui/WidgetCanvas'; +export { WidgetCanvas, WidgetCanvasSkeleton } from '@/entities/widget/ui/WidgetCanvas'; +export { WidgetSurface, type SurfaceBlockSlot } from '@/entities/widget/ui/WidgetSurface'; export { ScaledWidgetFrame } from '@/entities/widget/ui/ScaledWidgetFrame'; export * from '@/entities/widget/model'; diff --git a/client/src/entities/widget/lib/canvasStyle.test.ts b/client/src/entities/widget/lib/canvasStyle.test.ts deleted file mode 100644 index 35b5b18..0000000 --- a/client/src/entities/widget/lib/canvasStyle.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { blockMetrics, blockStyleVars, canvasStyleVars } from '@/entities/widget/lib/canvasStyle'; - -it('exposes shared canvas geometry as CSS custom properties', () => { - const vars = canvasStyleVars('mint', { width: 600, height: 315 }) as Record; - expect(vars).toMatchObject({ - '--widget-width': '600px', - '--widget-height': '315px', - '--widget-padding': '24px', - '--widget-gap': '18px', - '--block-padding': '20px', - '--widget-light-accent': '#2caa8a', - }); -}); - -it('sizes block typography from the column span, like the SVG export', () => { - expect(blockMetrics({ x: 0, y: 0, width: 1, height: 1 }).contentWidth).toBe(225); - const half = blockStyleVars({ x: 1, y: 2, width: 1, height: 2 }) as Record; - const full = blockStyleVars({ x: 0, y: 0, width: 2, height: 1 }) as Record; - - expect(half).toMatchObject({ - gridColumn: '2 / span 1', - gridRow: '3 / span 2', - '--block-stat': '22.5px', - }); - expect(full).toMatchObject({ '--block-stat': '30px', '--block-meta': '12px' }); -}); diff --git a/client/src/entities/widget/lib/canvasStyle.ts b/client/src/entities/widget/lib/canvasStyle.ts deleted file mode 100644 index 1920828..0000000 --- a/client/src/entities/widget/lib/canvasStyle.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { CSSProperties } from 'react'; - -import type { BlockLayout, PaletteId, WidgetBlock } from '@/entities/widget/model'; -import { paletteTokens } from '@/entities/widget/model'; -import { - BLOCK_BORDER, - BLOCK_LINE_HEIGHT, - BLOCK_PADDING, - BLOCK_RADIUS, - CANVAS_RADIUS, - GRID_GAP, - HEATMAP_CELL, - HEATMAP_GAP, - MAX_GRID_COLUMNS, - TEXT_BLOCK_LINE_HEIGHT, - WIDGET_WIDTH, - blockBox, - blockContentWidth, - blockTypography, - canvasPadding, -} from '@shared/widget/geometry'; -import { WIDGET_FONT_FAMILY } from '@shared/widget/WidgetCanvas'; -import { difficultyColors, errorColor } from '@shared/widget/theme'; - -export const getBlockLayout = (block: WidgetBlock): BlockLayout => { - const value = block.config.layout; - if (!value || typeof value !== 'object') return { x: 0, y: 0, width: 1, height: 1 }; - const layout = value as Partial; - return { - x: typeof layout.x === 'number' ? layout.x : 0, - y: typeof layout.y === 'number' ? layout.y : 0, - width: typeof layout.width === 'number' ? layout.width : 1, - height: typeof layout.height === 'number' ? layout.height : 1, - }; -}; - -/** CSS custom properties shared by every widget canvas surface (colors + geometry). */ -export const canvasStyleVars = ( - palette: PaletteId, - { width = WIDGET_WIDTH, height }: { width?: number; height?: number } = {}, -) => { - const tokens = paletteTokens[palette] ?? paletteTokens.lavender; - return { - '--widget-light-accent': tokens.light.accent, - '--widget-light-soft': tokens.light.soft, - '--widget-light-ink': tokens.light.ink, - '--widget-light-surface': tokens.light.surface, - '--widget-dark-accent': tokens.dark.accent, - '--widget-dark-soft': tokens.dark.soft, - '--widget-dark-ink': tokens.dark.ink, - '--widget-dark-surface': tokens.dark.surface, - '--widget-width': `${width}px`, - '--widget-height': height ? `${height}px` : undefined, - '--widget-padding': `${canvasPadding(width)}px`, - '--widget-gap': `${GRID_GAP}px`, - '--widget-radius': `${CANVAS_RADIUS}px`, - '--widget-font': WIDGET_FONT_FAMILY, - '--block-padding': `${BLOCK_PADDING}px`, - '--block-border': `${BLOCK_BORDER}px`, - '--block-radius': `${BLOCK_RADIUS}px`, - '--heatmap-cell': `${HEATMAP_CELL}px`, - '--heatmap-gap': `${HEATMAP_GAP}px`, - '--block-line-height': BLOCK_LINE_HEIGHT, - '--text-line-height': TEXT_BLOCK_LINE_HEIGHT, - '--color-easy': difficultyColors.easy, - '--color-medium': difficultyColors.medium, - '--color-hard': difficultyColors.hard, - '--color-error': errorColor, - } as CSSProperties; -}; - -/** Content width and typography of a block; depends only on its column span. */ -export const blockMetrics = ( - layout: BlockLayout, - widgetWidth = WIDGET_WIDTH, - columns = MAX_GRID_COLUMNS, -) => { - const box = blockBox(layout, { width: widgetWidth, height: 0, columns, rows: 1 }); - const contentWidth = blockContentWidth(box.width); - return { contentWidth, typography: blockTypography(contentWidth) }; -}; - -/** Grid placement and typography for one block, computed like the SVG export does. */ -export const blockStyleVars = ( - layout: BlockLayout, - widgetWidth = WIDGET_WIDTH, - columns = MAX_GRID_COLUMNS, -) => { - const { typography: t } = blockMetrics(layout, widgetWidth, columns); - return { - gridColumn: `${layout.x + 1} / span ${layout.width}`, - gridRow: `${layout.y + 1} / span ${layout.height}`, - '--block-meta': `${t.meta}px`, - '--block-heading': `${t.heading}px`, - '--block-stat': `${t.statValue}px`, - '--block-text': `${t.text}px`, - '--block-avatar': `${t.avatar}px`, - '--block-heading-gap': `${t.headingGap}px`, - '--block-section-gap': `${t.sectionGap}px`, - '--block-column-gap': `${t.columnGap}px`, - '--block-row-gap': `${t.rowGap}px`, - } as CSSProperties; -}; diff --git a/client/src/entities/widget/ui/WidgetCanvas.module.css b/client/src/entities/widget/ui/WidgetCanvas.module.css deleted file mode 100644 index 40f75d2..0000000 --- a/client/src/entities/widget/ui/WidgetCanvas.module.css +++ /dev/null @@ -1,455 +0,0 @@ -/* - * Widget canvas. All sizes come from CSS custom properties set by `canvasStyleVars` and - * `blockStyleVars` (see @shared/widget/geometry), so the editor, the public page, the iframe - * and the SVG export render identical geometry. Do not introduce viewport- or - * container-relative units here: scaling is done by `ScaledWidgetFrame`. - */ -.canvas { - --widget-accent: var(--widget-light-accent); - --widget-soft: var(--widget-light-soft); - --widget-ink: var(--widget-light-ink); - --widget-surface: var(--widget-light-surface); - box-sizing: border-box; - display: grid; - grid-template-rows: minmax(0, 1fr); - width: var(--widget-width); - height: var(--widget-height); - padding: var(--widget-padding); - overflow: hidden; - color: var(--widget-ink); - font-family: var(--widget-font); - font-size: 16px; - line-height: var(--block-line-height); - text-align: left; - border: 1px solid color-mix(in srgb, var(--widget-accent) 25%, transparent); - border-radius: var(--widget-radius); - background: - radial-gradient( - circle at 92% 2%, - color-mix(in srgb, var(--widget-accent) 22%, transparent), - transparent 32% - ), - linear-gradient(145deg, var(--widget-surface), var(--widget-soft)); -} - -.canvas *, -.canvas *::before, -.canvas *::after { - box-sizing: border-box; -} - -.canvasSkeleton { - --widget-accent: var(--action); - --widget-soft: var(--glass); - --widget-ink: var(--text); - --widget-surface: var(--glass-strong); -} - -.canvas[data-palette-mode='dark'] { - --widget-accent: var(--widget-dark-accent); - --widget-soft: var(--widget-dark-soft); - --widget-ink: var(--widget-dark-ink); - --widget-surface: var(--widget-dark-surface); -} - -.blocks { - display: grid; - grid-template-columns: repeat(var(--widget-columns, 2), minmax(0, 1fr)); - grid-template-rows: repeat(var(--widget-rows, 1), minmax(0, 1fr)); - gap: var(--widget-gap); - min-height: 0; - height: 100%; -} - -.block { - min-width: 0; - min-height: 0; - overflow: hidden; - padding: var(--block-padding); - border: var(--block-border) solid color-mix(in srgb, var(--widget-accent) 18%, transparent); - border-radius: var(--block-radius); - background: color-mix(in srgb, var(--widget-surface) 72%, var(--widget-soft)); - transition: - border-color 160ms ease, - box-shadow 160ms ease; -} - -.skeletonBlock { - display: grid; - align-content: center; -} - -.blockSkeleton { - display: grid; - gap: 12px; -} - -.skeletonLine, -.skeletonStat { - display: block; - border-radius: 999px; - background: linear-gradient( - 100deg, - color-mix(in srgb, var(--widget-ink) 10%, transparent) 20%, - color-mix(in srgb, var(--widget-accent) 28%, transparent) 45%, - color-mix(in srgb, var(--widget-ink) 10%, transparent) 70% - ); - background-size: 220% 100%; - animation: widget-skeleton-shimmer 1.2s linear infinite; -} - -.skeletonLine { - height: 10px; -} - -.skeletonLineWide { - width: 68%; -} - -.skeletonLineMedium { - width: 46%; -} - -.skeletonLineShort { - width: 30%; -} - -.skeletonStats { - display: flex; - gap: 10px; - margin-top: 8px; -} - -.skeletonStat { - width: 28%; - height: 38px; - border-radius: 12px; -} - -.selected, -.canvas[data-interactive='true'] .block:hover { - border-color: color-mix(in srgb, var(--widget-accent) 66%, transparent); - box-shadow: 0 10px 30px color-mix(in srgb, var(--widget-accent) 12%, transparent); -} - -/* Shared text roles. SVG mirrors: heading 800, meta 500 at 60% ink. */ -.blockHeading strong, -.blockTitleRow strong { - overflow: hidden; - font-size: var(--block-heading); - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; -} - -.blockHeading span, -.blockTitleRow span, -.stat span, -.languageList, -.difficultyRow { - color: color-mix(in srgb, var(--widget-ink) 60%, transparent); - font-size: var(--block-meta); - font-weight: 500; -} - -.blockHeading span, -.blockTitleRow span, -.stat span, -.stat strong, -.languageList span, -.difficultyRow span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.statsBlock { - display: grid; - align-content: start; - gap: var(--block-section-gap); -} - -.blockHeading { - display: flex; - align-items: center; - gap: var(--block-heading-gap); -} - -.blockHeading > div:last-child { - display: grid; - min-width: 0; - gap: clamp(2px, calc(var(--block-meta) / 4), 3px); -} - -.blockTitleRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; -} - -.blockTitleRow span { - flex: 0 1 auto; - max-width: 45%; -} - -.avatar { - display: grid; - width: var(--block-avatar); - height: var(--block-avatar); - flex: 0 0 auto; - place-items: center; - overflow: hidden; - border-radius: 14px; - background: color-mix(in srgb, var(--widget-accent) 22%, transparent); -} - -.avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.statsRow { - display: grid; - grid-template-columns: repeat(var(--stat-count, 3), minmax(0, 1fr)); - gap: var(--block-column-gap); -} - -.stat { - display: grid; - min-width: 0; - gap: 3px; -} - -.stat strong { - font-size: var(--block-stat); - font-weight: 800; - letter-spacing: -0.06em; -} - -.languageBar { - display: flex; - height: 10px; - overflow: hidden; - border-radius: 5px; - background: color-mix(in srgb, var(--widget-ink) 10%, transparent); -} - -.languageBar span { - display: block; - flex: 0 0 auto; - height: 100%; -} - -.languageList { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--block-row-gap) var(--block-column-gap); -} - -.languageList > span { - display: flex; - align-items: center; - min-width: 0; -} - -.languageList > span > span { - flex: 1 1 auto; - min-width: 0; -} - -.languageList i, -.difficultyRow i { - display: inline-block; - width: 7px; - height: 7px; - flex: 0 0 auto; - margin-right: 6px; - border-radius: 50%; -} - -.languageList b { - flex: 0 0 auto; - margin-left: 6px; - color: var(--widget-ink); - font-weight: 700; -} - -.textBlock { - margin: 0; - overflow: hidden; - font-size: var(--block-text); - font-weight: 800; - line-height: var(--text-line-height); - letter-spacing: -0.05em; - overflow-wrap: anywhere; -} - -.difficultyRow { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--block-column-gap); -} - -.difficultyRow .easy { - background: var(--color-easy); -} -.difficultyRow .medium { - background: var(--color-medium); -} -.difficultyRow .hard { - background: var(--color-hard); -} - -.heatmap { - display: flex; - gap: var(--heatmap-gap); -} - -.heatmap > span { - display: grid; - grid-template-rows: repeat(7, var(--heatmap-cell)); - gap: var(--heatmap-gap); -} - -.heatmap i { - display: block; - width: var(--heatmap-cell); - height: var(--heatmap-cell); - border-radius: 2.5px; - background: var(--widget-accent); -} - -.heatmap i[data-level='0'] { - background: var(--widget-ink); -} - -.pr-merged { - background: var(--widget-accent) !important; -} - -.pr-open { - background: color-mix(in srgb, var(--widget-accent) 45%, transparent) !important; -} - -.pr-closed { - background: color-mix(in srgb, var(--widget-ink) 20%, transparent) !important; -} - -/* Mirrors the SVG: emoji line, message (up to 3 lines), busy chip. */ -.status { - display: grid; - justify-items: start; - gap: var(--block-heading-gap); -} - -.statusEmoji { - font-size: var(--block-text); - line-height: var(--block-line-height); -} - -.statusMessage { - display: -webkit-box; - margin: 0; - overflow: hidden; - font-size: var(--block-heading); - font-weight: 700; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; - overflow-wrap: anywhere; -} - -.statusBusy { - padding: 4px 10px; - color: var(--widget-accent); - border-radius: 999px; - background: color-mix(in srgb, var(--widget-accent) 14%, transparent); - font-size: var(--block-meta); - font-weight: 700; -} - -.statusEmpty { - color: color-mix(in srgb, var(--widget-ink) 60%, transparent); - font-size: var(--block-meta); -} - -.error { - margin: 0; - color: var(--color-error); - font-size: 13px; - line-height: 1.5; -} - -.previewState { - display: flex; - min-height: 76px; - align-items: center; - gap: 12px; - padding: 14px; - border: 1px dashed color-mix(in srgb, var(--widget-accent) 30%, transparent); - border-radius: 14px; - background: color-mix(in srgb, var(--widget-accent) 7%, transparent); -} - -.previewStateMark { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - color: var(--widget-accent); - border: 1px solid color-mix(in srgb, var(--widget-accent) 34%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--widget-accent) 12%, transparent); - font-size: 13px; - font-weight: 800; -} - -.previewStateCopy { - display: grid; - min-width: 0; - gap: 4px; -} - -.previewStateCopy strong { - font-size: 13px; - font-weight: 700; -} - -.previewStateCopy span { - overflow: hidden; - color: color-mix(in srgb, var(--widget-ink) 62%, transparent); - font-size: 11px; - line-height: 1.4; - text-overflow: ellipsis; - white-space: nowrap; -} - -@keyframes widget-skeleton-shimmer { - from { - background-position: 100% 0; - } - - to { - background-position: -100% 0; - } -} - -.empty { - align-self: center; - margin: 0; - padding: 42px 16px; - color: color-mix(in srgb, var(--widget-ink) 60%, transparent); - font-size: 14px; - text-align: center; -} - -@media (prefers-reduced-motion: reduce) { - .skeletonLine, - .skeletonStat { - animation: none; - } -} diff --git a/client/src/entities/widget/ui/WidgetCanvas.test.tsx b/client/src/entities/widget/ui/WidgetCanvas.test.tsx index 5e1710b..da7609c 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.test.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.test.tsx @@ -34,7 +34,8 @@ it('renders live stats with localized labels and fits long numbers', () => { ); expect(screen.getByText('The Octocat')).toBeInTheDocument(); - expect(screen.getByText('Подписчики')).toBeInTheDocument(); + // Labels are cut to the stat column like in the SVG export. + expect(screen.getAllByText(/^Подпис/)).toHaveLength(2); // A 1-column block is too narrow for "24,351" at stat size, so it switches to compact. expect(screen.getByText('24.4K')).toBeInTheDocument(); }); @@ -49,11 +50,13 @@ it('renders at the stored widget size with the palette mode applied', () => { height={315} />, ); - const canvas = container.firstElementChild as HTMLElement; + const surface = container.firstElementChild as HTMLElement; - expect(canvas).toHaveAttribute('data-palette-mode', 'dark'); - expect(canvas.style.getPropertyValue('--widget-width')).toBe('600px'); - expect(canvas.style.getPropertyValue('--widget-height')).toBe('315px'); + expect(surface.style.width).toBe('600px'); + expect(surface.style.height).toBe('315px'); + // Mint, dark mode. + expect(surface.style.getPropertyValue('--widget-accent')).toBe('#73d9b8'); + expect(container.querySelector('svg')).toHaveAttribute('viewBox', '0 0 600 315'); }); it('asks for a username before a data block can load', () => { @@ -65,7 +68,7 @@ it('asks for a username before a data block can load', () => { />, ); - expect(screen.getByRole('status')).toHaveTextContent('Add a GitHub username in block settings'); + expect(screen.getByText('Add a username')).toBeInTheDocument(); }); it('shows block errors instead of data', () => { @@ -108,7 +111,7 @@ describe('GitHub activity, pull request and status blocks', () => { renderedBlocks={[rendered('narrow')]} />, ); - expect(container.querySelectorAll('[data-level]')).toHaveLength(17 * 7); + expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(17 * 7); rerender( { />, ); // Full-width block: (510 + 3) / 13 → 39 weeks. - expect(container.querySelectorAll('[data-level]')).toHaveLength(39 * 7); + expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(39 * 7); }); it('hides streaks and the heatmap when turned off', () => { @@ -139,7 +142,7 @@ describe('GitHub activity, pull request and status blocks', () => { ); expect(screen.getByText('Коммиты')).toBeInTheDocument(); expect(screen.queryByText('Дней подряд')).not.toBeInTheDocument(); - expect(container.querySelector('[data-level]')).toBeNull(); + expect(container.querySelector('rect[width="10"][height="10"]')).toBeNull(); }); it('shows the pull request breakdown in percent', () => { diff --git a/client/src/entities/widget/ui/WidgetCanvas.tsx b/client/src/entities/widget/ui/WidgetCanvas.tsx index 388f945..423c5de 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -1,29 +1,13 @@ -import type { CSSProperties, MouseEvent } from 'react'; - import type { BlockLayout, - BlockType, PaletteId, PaletteMode, RenderedBlock, WidgetBlock, } from '@/entities/widget/model'; -import { - blockMetrics, - blockStyleVars, - canvasStyleVars, - getBlockLayout, -} from '@/entities/widget/lib/canvasStyle'; -import { - MAX_GRID_COLUMNS, - WIDGET_WIDTH, - gridRows, - heatmapColumns, - heatmapWeeks, - widgetDimensions, -} from '@shared/widget/geometry'; -import { formatStatValue, heatmapOpacity, languageColor, widgetLabels } from '@shared/widget/theme'; -import styles from '@/entities/widget/ui/WidgetCanvas.module.css'; +import { WidgetSurface } from '@/entities/widget/ui/WidgetSurface'; +import { MAX_GRID_COLUMNS, WIDGET_WIDTH, widgetDimensions } from '@shared/widget/geometry'; +import { layoutOf } from '@shared/widget/svgPrimitives'; type WidgetLocale = 'ru' | 'en'; @@ -35,419 +19,13 @@ type WidgetCanvasProps = { width?: number; height?: number; renderedBlocks?: RenderedBlock[]; - interactive?: boolean; - selectedBlockId?: string; - onSelectBlock?: (id: string) => void; - locale?: WidgetLocale; -}; - -const sampleData: Record> = { - text: {}, - 'github-stats': { - username: 'octocat', - name: 'The Octocat', - publicRepositories: 42, - followers: 4_321, - following: 12, - }, - 'github-langs': { - languages: [ - { name: 'TypeScript', percentage: 54 }, - { name: 'JavaScript', percentage: 24 }, - { name: 'CSS', percentage: 14 }, - { name: 'Other', percentage: 8 }, - ], - }, - 'github-commits': { - username: 'octocat', - commitsYear: 1_284, - currentStreak: 12, - longestStreak: 47, - levels: Array.from({ length: 364 }, (_, day) => [0, 1, 2, 1, 3, 0, 4, 2, 1][(day * 7) % 9]), - firstDayOfWeek: 0, - }, - 'github-prs': { username: 'octocat', total: 214, merged: 176, open: 6, closed: 32 }, - 'github-status': { - username: 'octocat', - emoji: '🚀', - message: 'Shipping new widgets', - busy: false, - }, - 'leetcode-stats': { - username: 'your-profile', - ranking: 18_240, - contestRating: 1_726, - solved: { all: 312, easy: 148, medium: 132, hard: 32 }, - }, -}; - -const renderedData = (block: WidgetBlock, renderedBlocks?: RenderedBlock[]) => - renderedBlocks?.find((rendered) => rendered.id === block.id); - -const PreviewState = ({ - locale, - source, -}: { - locale: WidgetLocale; - source: 'GitHub' | 'LeetCode'; -}) => { - const labels = widgetLabels(locale); - return ( -
- - - {labels.addUsername} - {labels.addUsernameHint(source)} - -
- ); -}; - -const WidgetBlockSkeleton = () => ( - -); - -const numberFormatter = new Intl.NumberFormat('en-US'); - -const formatNumber = (value: unknown, fallback = '—') => - typeof value === 'number' && Number.isFinite(value) ? numberFormatter.format(value) : fallback; - -type BlockData = { - commitsYear?: number; - currentStreak?: number; - longestStreak?: number; - levels?: number[]; - firstDayOfWeek?: number; - total?: number; - merged?: number; - open?: number; - closed?: number; - emoji?: string | null; - message?: string | null; - busy?: boolean; - username?: string; - name?: string; - avatarUrl?: string; - publicRepositories?: number; - followers?: number; - following?: number; - ranking?: number | null; - contestRating?: number | null; - solved?: { all?: number; easy?: number; medium?: number; hard?: number }; - languages?: { name: string; percentage: number }[]; -}; - -type StatItem = { label: string; value: unknown }; - -const StatsRow = ({ items, block }: { items: StatItem[]; block: WidgetBlock }) => { - const { contentWidth, typography: t } = blockMetrics(getBlockLayout(block)); - const columnWidth = (contentWidth - t.columnGap * (items.length - 1)) / Math.max(items.length, 1); - return ( -
- {items.map((item) => ( -
- {formatStatValue(item.value, columnWidth, t.statValue)} - {item.label} -
- ))} -
- ); -}; - -export const WidgetBlockContent = ({ - block, - rendered, - locale = 'en', -}: { - block: WidgetBlock; - rendered?: RenderedBlock; locale?: WidgetLocale; -}) => { - const labels = widgetLabels(locale); - if (rendered?.error) return

{rendered.error}

; - const source = block.type.startsWith('github') - ? 'GitHub' - : block.type.startsWith('leetcode') - ? 'LeetCode' - : null; - const username = typeof block.config.username === 'string' ? block.config.username.trim() : ''; - if (source && rendered?.data === undefined && !username) { - return ; - } - if (source && rendered?.data === undefined && !rendered) { - return ; - } - const data = (rendered?.data as BlockData | undefined) ?? (sampleData[block.type] as BlockData); - - if (block.type === 'text') { - const align = - block.config.align === 'center' || block.config.align === 'right' - ? block.config.align - : 'left'; - const text = typeof block.config.text === 'string' ? block.config.text.trim() : ''; - return ( -

- {text || labels.defaultText} -

- ); - } - - if (block.type === 'github-stats') { - const stats = [ - block.config.showRepositories !== false - ? { label: labels.repositories, value: data.publicRepositories } - : null, - block.config.showFollowers !== false - ? { label: labels.followers, value: data.followers } - : null, - block.config.showFollowing !== false - ? { label: labels.following, value: data.following } - : null, - ].filter((item) => item !== null); - return ( -
-
- -
- {data.name || labels.githubProfile} - @{data.username || username || 'username'} -
-
- {stats.length > 0 && } -
- ); - } - - if (block.type === 'github-langs') { - const languages = (data.languages ?? []).slice(0, 8); - return ( -
-
- {labels.languages} - - {labels.top} {languages.length} - -
-
- {languages.map((language) => ( - - ))} -
-
- {languages.map((language) => ( - - - {language.name} - {language.percentage}% - - ))} -
-
- ); - } - - if (block.type === 'github-commits') { - const stats = [ - { label: labels.commits, value: data.commitsYear }, - ...(block.config.showStreak !== false - ? [ - { label: labels.currentStreak, value: data.currentStreak }, - { label: labels.longestStreak, value: data.longestStreak }, - ] - : []), - ]; - const columns = heatmapColumns( - data.levels ?? [], - heatmapWeeks(blockMetrics(getBlockLayout(block)).contentWidth), - data.firstDayOfWeek ?? 0, - ); - return ( -
-
- {labels.activity} - @{data.username || username || 'username'} -
- - {block.config.showHeatmap !== false && ( - - )} -
- ); - } - - if (block.type === 'github-prs') { - const total = data.total ?? 0; - const parts = [ - { key: 'merged', label: labels.merged, value: data.merged ?? 0 }, - { key: 'open', label: labels.open, value: data.open ?? 0 }, - { key: 'closed', label: labels.closed, value: data.closed ?? 0 }, - ]; - return ( -
-
- {labels.pullRequests} - @{data.username || username || 'username'} -
- - {block.config.showBreakdown !== false && ( - <> -
- {total > 0 && - parts.map((part) => ( - - ))} -
-
- {parts.map((part) => ( - - - {part.label} - {total > 0 ? Math.round((part.value / total) * 100) : 0}% - - ))} -
- - )} -
- ); - } - - if (block.type === 'github-status') { - return ( -
-
- {labels.status} - @{data.username || username || 'username'} -
- {!data.emoji && !data.message ? ( - {labels.noStatus} - ) : ( -
- {data.emoji && {data.emoji}} - {data.message &&

{data.message}

} - {data.busy && {labels.busy}} -
- )} -
- ); - } - - const solved = data.solved ?? {}; - const stats = [ - { label: labels.solved, value: solved.all }, - block.config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null, - block.config.showContestRating !== false - ? { label: labels.contestRating, value: data.contestRating } - : null, - ].filter((item) => item !== null); - return ( -
-
- {labels.leetcodeProfile} - @{data.username || username || 'username'} -
- -
- - {labels.easy} {formatNumber(solved.easy, '0')} - - - {labels.medium} {formatNumber(solved.medium, '0')} - - - {labels.hard} {formatNumber(solved.hard, '0')} - -
-
- ); + title?: string; }; -type WidgetCanvasSkeletonProps = { - locale?: WidgetLocale; - width?: number; - height?: number; -}; - -const skeletonLayouts: BlockLayout[] = [ - { x: 0, y: 0, width: 1, height: 1 }, - { x: 1, y: 0, width: 1, height: 1 }, -]; - -export const WidgetCanvasSkeleton = ({ - locale = 'en', - width = WIDGET_WIDTH, - height = widgetDimensions(skeletonLayouts).height, -}: WidgetCanvasSkeletonProps) => ( -
- -
-); - /** - * HTML widget renderer used by the editor, the public page and the iframe embed. It always - * renders at the stored widget size; wrap it in `ScaledWidgetFrame` to fit smaller containers. - * Geometry must stay in sync with the SVG export in `@shared/widget/WidgetCanvas`. + * The widget as shown on the public page and in iframes, at its stored size. Wrap it in + * `ScaledWidgetFrame` to fit smaller containers. Rendering is shared with the SVG export. */ export const WidgetCanvas = ({ blocks, @@ -457,53 +35,51 @@ export const WidgetCanvas = ({ width = WIDGET_WIDTH, height, renderedBlocks, - interactive = false, - selectedBlockId, - onSelectBlock, locale = 'en', -}: WidgetCanvasProps) => { - const gridColumns = Math.max(1, Math.min(columns, MAX_GRID_COLUMNS)); - const layouts = blocks.map(getBlockLayout); - const canvasHeight = height ?? widgetDimensions(layouts, width).height; - const style = { - ...canvasStyleVars(palette, { width, height: canvasHeight }), - '--widget-columns': gridColumns, - '--widget-rows': gridRows(layouts), - } as CSSProperties; + title, +}: WidgetCanvasProps) => ( + +); - const handleSelect = (event: MouseEvent, id: string) => { - if (!interactive || !onSelectBlock) return; - event.stopPropagation(); - onSelectBlock(id); - }; +const skeletonLayouts: BlockLayout[] = [ + { x: 0, y: 0, width: 1, height: 1 }, + { x: 1, y: 0, width: 1, height: 1 }, +]; - return ( -
- {blocks.length === 0 ? ( -

{widgetLabels(locale).empty}

- ) : ( -
- {blocks.map((block, index) => ( -
handleSelect(event, block.id)} - > - -
- ))} -
- )} -
- ); -}; +// Data blocks with a username and no data yet render as loading placeholders. +const skeletonBlocks: WidgetBlock[] = skeletonLayouts.map((layout, index) => ({ + id: `skeleton-${index}`, + type: 'github-stats', + position: index, + config: { username: 'loading', layout }, +})); + +export const WidgetCanvasSkeleton = ({ + locale = 'en', + width = WIDGET_WIDTH, + height = widgetDimensions(skeletonLayouts).height, +}: { + locale?: WidgetLocale; + width?: number; + height?: number; +}) => ( +
+ +
+); diff --git a/client/src/entities/widget/ui/WidgetSurface.module.css b/client/src/entities/widget/ui/WidgetSurface.module.css new file mode 100644 index 0000000..b19561d --- /dev/null +++ b/client/src/entities/widget/ui/WidgetSurface.module.css @@ -0,0 +1,20 @@ +.surface { + position: relative; + overflow: hidden; + border-radius: 28px; +} + +.layer { + position: absolute; + inset: 0; + display: block; +} + +.block { + position: absolute; +} + +.blockSvg { + display: block; + overflow: visible; +} diff --git a/client/src/entities/widget/ui/WidgetSurface.tsx b/client/src/entities/widget/ui/WidgetSurface.tsx new file mode 100644 index 0000000..93141e5 --- /dev/null +++ b/client/src/entities/widget/ui/WidgetSurface.tsx @@ -0,0 +1,126 @@ +import { useId, type CSSProperties, type ReactNode } from 'react'; + +import type { PaletteId, PaletteMode, RenderedBlock, WidgetBlock } from '@/entities/widget/model'; +import { + BlockShell, + CanvasBackground, + CanvasEmptyState, + canvasBoxes, + canvasTokens, + type BlockBox, +} from '@shared/widget/canvasParts'; +import { widgetLabels } from '@shared/widget/theme'; +import styles from '@/entities/widget/ui/WidgetSurface.module.css'; + +export type SurfaceBlockSlot = { block: WidgetBlock; box: BlockBox; content: ReactNode }; + +type WidgetSurfaceProps = { + blocks: WidgetBlock[]; + renderedBlocks?: RenderedBlock[]; + palette: PaletteId; + paletteMode?: PaletteMode; + width: number; + height: number; + columns?: number; + /** Row count override (the editor adds a spare drop row while dragging). */ + rows?: number; + locale?: 'ru' | 'en'; + emptyText?: string; + label?: string; + /** Wraps each positioned block, e.g. to add editor controls. Must return a keyed element. */ + renderBlock?: (slot: SurfaceBlockSlot) => ReactNode; + /** Extra layers on top of the blocks (drop targets etc.). */ + children?: ReactNode; +}; + +/** + * Browser rendering of a widget at its stored size, built from the same SVG parts as the + * `/image.svg` export: the background and each block are separate SVGs placed with the shared + * geometry, so the editor can attach DOM controls to individual blocks. + */ +export const WidgetSurface = ({ + blocks, + renderedBlocks, + palette, + paletteMode = 'light', + width, + height, + columns, + rows, + locale = 'en', + emptyText, + label, + renderBlock, + children, +}: WidgetSurfaceProps) => { + const idPrefix = `w${useId().replace(/[^a-z0-9]/gi, '')}`; + const tokens = canvasTokens(palette, paletteMode); + const boxes = canvasBoxes(blocks, { width, height, columns, rows }); + + return ( +
+ + {blocks.map((block, index) => { + const box = boxes[index]; + const content = ( + + item.id === block.id)} + box={{ x: 0, y: 0, width: box.width, height: box.height }} + tokens={tokens} + locale={locale} + idPrefix={`${idPrefix}-block`} + /> + + ); + return renderBlock ? ( + renderBlock({ block, box, content }) + ) : ( +
+ {content} +
+ ); + })} + {children} +
+ ); +}; diff --git a/client/src/pages/widget-editor/ui/EditorBlock.tsx b/client/src/pages/widget-editor/ui/EditorBlock.tsx index f517fda..b5b4883 100644 --- a/client/src/pages/widget-editor/ui/EditorBlock.tsx +++ b/client/src/pages/widget-editor/ui/EditorBlock.tsx @@ -1,12 +1,10 @@ import { Grip, TrashBin } from '@gravity-ui/icons'; import { Icon } from '@gravity-ui/uikit'; -import type { PointerEvent } from 'react'; +import type { PointerEvent, ReactNode } from 'react'; -import { blockStyleVars, WidgetBlockContent } from '@/entities/widget'; -import type { RenderedBlock, WidgetBlock } from '@/entities/widget/model'; -import canvasStyles from '@/entities/widget/ui/WidgetCanvas.module.css'; +import type { WidgetBlock } from '@/entities/widget/model'; +import type { BlockBox } from '@shared/widget/canvasParts'; import { blockSizes, getLayout } from '@/pages/widget-editor/model/layout'; -import type { Locale } from '@/shared/locale/content'; import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; export type BlockPointerHandlers = { @@ -18,8 +16,10 @@ export type BlockPointerHandlers = { type EditorBlockProps = { block: WidgetBlock; - rendered?: RenderedBlock; - locale: Locale; + /** Position inside the canvas, in widget pixels. */ + box: BlockBox; + /** The block's SVG, rendered by WidgetSurface. */ + children: ReactNode; selected: boolean; dragging: boolean; removeLabel: string; @@ -31,8 +31,8 @@ type EditorBlockProps = { export const EditorBlock = ({ block, - rendered, - locale, + box, + children, selected, dragging, removeLabel, @@ -44,9 +44,9 @@ export const EditorBlock = ({ const layout = getLayout(block); return (