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
23 changes: 21 additions & 2 deletions e2e/studio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test, type Page } from '@playwright/test';
import { getInitialTemplateLayers } from '../src/data/studioTemplates';

const MARK = 'E2E-LAYER';
const CUSTOM_FONT_FIXTURE = 'e2e/fixtures/wide-space.ttf';

const readSavedDocument = (page: Page) =>
page.evaluate(
Expand Down Expand Up @@ -162,7 +163,7 @@ test('free-form Konva tab sends a shape to the main canvas; chart tab renders',
test('uploaded custom font is embedded in exports', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'فونت', exact: true }).first().click();
await page.locator('input[type="file"][accept*=".ttf"]').first().setInputFiles('/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf');
await page.locator('input[type="file"][accept*=".ttf"]').first().setInputFiles(CUSTOM_FONT_FIXTURE);
await page.waitForTimeout(800);
const css = await page.evaluate(async () => {
const { getFontEmbedCSS } = await import(/* @vite-ignore */ String('/node_modules/.vite/deps/html-to-image.js'));
Expand All @@ -174,7 +175,7 @@ test('uploaded custom font is embedded in exports', async ({ page }) => {
test('uploaded font does not leak into the UI', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'فونت', exact: true }).first().click();
await page.locator('input[type="file"][accept*=".ttf"]').first().setInputFiles('e2e/fixtures/wide-space.ttf');
await page.locator('input[type="file"][accept*=".ttf"]').first().setInputFiles(CUSTOM_FONT_FIXTURE);
await page.waitForTimeout(800);
const fonts = await page.evaluate(() => ({
uiButton: getComputedStyle(document.querySelector('header button')!).fontFamily,
Expand Down Expand Up @@ -254,3 +255,21 @@ test('no UI element spills out of its panel at 125% scaling', async ({ page }) =
const real = [...new Set(all)].filter((l) => !/ -\d{4}/.test(l) && !/\| (svg|g|text)$/.test(l));
expect(real).toEqual([]);
});

test('infographic grows with layers added beyond the base height', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: /مارکت ژاکت/ }).first().click();
await page.evaluate(async () => {
const { useStudioStore } = await import(/* @vite-ignore */ String('/src/store/index.ts'));
useStudioStore.getState().setCanvasLayers((m: any) => ({
...m,
infographic: [{ id: 'info-e2e', name: 'INFO-E2E', type: 'text', x: 20, y: 900, scale: 1, rotation: 0, visible: true, locked: false, zIndex: 5, data: { text: 'INFO-E2E-TEXT', fontSize: 24, color: '#ffffff', width: 200, height: 60 } }],
}));
});
await page.locator('button:has-text("اینفوگرافی")').first().click();
const wrap = page.locator('[data-testid="infographic-layers"]');
await expect(wrap).toBeAttached();
await expect(page.getByText('INFO-E2E-TEXT').first()).toBeAttached();
const h = await wrap.evaluate((el) => el.getBoundingClientRect().height);
expect(h).toBeGreaterThan(430);
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "kityar",
"productName": "KitYar",
"version": "1.0.2",
"version": "1.0.4",
"description": "کیت یار — دستیار طراحی هوشمند و اتوماسیون کاور محصولات برای مارکت‌پلیس‌های ایرانی",
"author": "EHSANKiNG",
"license": "UNLICENSED",
Expand Down
90 changes: 81 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { parseProjectFile, MAX_PROJECT_FILE_BYTES } from './store/projectFile';
import { getAssetSpec, MARKETPLACES, type AssetId } from './features/marketplace/specs';
import { checkCompliance, type ComplianceReport } from './features/marketplace/compliance';
import { measureLayers } from './features/marketplace/measure';
import { getInfographicDisplayHeight } from './utils/infographicHeight';
import { getExportAssetKey, type ExportDimensions, type ExportTarget } from './utils/exportDimensions';
import { parseCsv } from './features/batch/csv';
import { useTemplateVars } from './features/batch/template';
Expand Down Expand Up @@ -121,12 +122,12 @@ export function App() {
setComplianceReport({ ...checkCompliance(spec, measures), assetLabel: spec.label, platform: targetPlatform });
};

const getWorkspaceDisplaySize = (platform: string, assetId: string) => {
const getWorkspaceDisplaySize = (platform: string, assetId: string, layers?: CanvasLayerItem[]) => {
const { width: realW, height: realH } = getAssetDimensions(platform, assetId);

// For infographic of zhaket, keep tall
if (assetId === 'infographic' && platform === 'zhaket') {
return { displayWidth: 350, displayHeight: 430, scale: 350 / realW };
return { displayWidth: 350, displayHeight: getInfographicDisplayHeight(layers ?? canvasLayers.infographic, 430, Math.round(realH * 350 / realW)), scale: 350 / realW };
}

const maxW = 380;
Expand Down Expand Up @@ -594,6 +595,37 @@ export function App() {
setTimeout(() => setToastMessage(null), 3000);
};

const handleAddChartLayer = (chartConfig: Partial<CanvasLayerItem>) => {
const newId = `chart-${Date.now()}`;
const { displayWidth, displayHeight } = getWorkspaceDisplaySize(targetPlatform, activeAsset);
const fitScale = Math.min(1, displayWidth / 340, displayHeight / 230);
const width = Math.round(340 * fitScale);
const height = Math.round(230 * fitScale);
const newLayer: CanvasLayerItem = {
id: newId,
name: chartConfig.name || 'نمودار تحلیل محصول',
type: 'chart',
visible: true,
locked: false,
zIndex: (currentLayers.length + 1) * 10,
scale: 1,
rotation: 0,
opacity: 100,
x: Math.max(0, Math.round((displayWidth - width) / 2)),
y: Math.max(0, Math.round((displayHeight - height) / 2)),
data: {
...chartConfig.data,
width,
height,
},
};

setCanvasLayers((prev) => appendLayer(prev, activeAsset, newLayer));
setActiveLayerId(newId);
setToastMessage('نمودار تحلیل محصول به بوم افزوده شد.');
setTimeout(() => setToastMessage(null), 3000);
};

const handleAddImageLayer = (imageConfig: Partial<CanvasLayerItem>) => {
const newId = `img-${Date.now()}`;
const newLayer: CanvasLayerItem = {
Expand Down Expand Up @@ -1574,6 +1606,7 @@ export function App() {

const { displayWidth: activeDispW, displayHeight: activeDispH, scale: activeScale } = getWorkspaceDisplaySize(targetPlatform, activeAsset);
const { width: activeRealW, height: activeRealH, label: activeRealLabel } = getAssetDimensions(targetPlatform, activeAsset);
const exportInfographicDisplay = getWorkspaceDisplaySize(targetPlatform, 'infographic', canvasLayers.infographic);
const exportLogoDisplay = getWorkspaceDisplaySize(targetPlatform, 'logo80');
const exportCover400Display = getWorkspaceDisplaySize(targetPlatform, 'cover400');
const exportCover700_1Display = getWorkspaceDisplaySize(targetPlatform, 'cover700_1');
Expand Down Expand Up @@ -1664,6 +1697,23 @@ export function App() {
>
<RenderPlacedLogo config={logoConfig} isDraggable={false} />
<CanvasStickersOverlay stickers={canvasStickers.infographic || []} isEditable={false} />
<div className="pointer-events-none absolute left-0 top-0 z-20" style={{ width: 350, transform: `scale()`, transformOrigin: 'top left' }}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scale infographic layers in the export stage

The infographic editor stores layer coordinates against its fixed 350px-wide canvas, but the export stage is rendered at the selected asset's native width (594px for Zhaket and up to 1200px elsewhere). scale() is invalid CSS and is discarded, so every newly supported infographic layer keeps its 350px offsets and dimensions in the native-width export; for example, a chart centered at x: 175 in the editor is exported well left of center. Supply the intended scale factor (with the existing top-left origin) so exports match the canvas.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Size the export target for absolute infographic layers

The newly added layer wrapper and all of its children are absolutely positioned, so they add no height to exportInfographicRef; exportInfographicDisplay.displayHeight is only passed to the renderer and never applied to the export root. When a user places a layer below the normal infographic rows (including the new test's y: 900 scenario after clearing or shortening rows), html-to-image captures only the root's normal-flow height and crops that layer from PNG/PDF/PSD/ZIP exports. Set the export stage's minimum height from the computed infographic display height before capturing it.

Useful? React with 👍 / 👎.

{canvasLayers.infographic.filter(l => l.type !== 'background').map(l => (
<DynamicLayerRenderer
key={l.id}
layer={l}
otherLayers={canvasLayers.infographic}
isSelected={false}
onSelect={() => {}}
onUpdateLayer={() => {}}
onUpdateLayerData={() => {}}
onUpdatePosition={() => {}}
isInteractive={false}
canvasWidth={350}
canvasHeight={exportInfographicDisplay.displayHeight}
/>
))}
</div>
<div className="text-center bg-slate-950/85 p-5 rounded-2xl border border-emerald-500/30 space-y-2 backdrop-blur-md">
<span className="text-xs text-emerald-400 font-extrabold bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20">اینفوگرافی رسمی محصول</span>
<h3 className="text-xl font-black text-white">{productName}</h3>
Expand Down Expand Up @@ -2092,7 +2142,7 @@ export function App() {
{/* 1. LEFT / CENTER: INTERACTIVE CANVAS WITH LIVE LAYERS */}
<div
ref={activeCanvasContainerRef}
className="lg:col-span-2 bg-slate-900 border border-slate-800 rounded-3xl p-6 flex flex-col items-center justify-center min-h-[540px] relative overflow-hidden shadow-2xl"
className="lg:col-span-2 min-w-0 bg-slate-900 border border-slate-800 rounded-3xl p-6 flex flex-col items-center justify-center min-h-[540px] relative overflow-hidden shadow-2xl"
onClick={() => setActiveLayerId(null)}
onContextMenu={(e) => handleOpenContextMenu(e)}
>
Expand Down Expand Up @@ -2547,7 +2597,7 @@ export function App() {
{activeAsset === 'infographic' && (
<CanvasRulers
width={350}
height={430}
height={activeDispH}
unit={rulerUnit}
showRulers={showRulers}
showCrosshairLines={showCrosshairLines}
Expand All @@ -2558,8 +2608,8 @@ export function App() {
>
<div
ref={activeAsset === 'infographic' ? activeLiveCanvasRef : undefined}
className="custom-scrollbar relative z-10 space-y-4 overflow-y-auto p-4 border-2 border-emerald-500/40 rounded-2xl w-[350px] h-[430px] shadow-2xl"
style={getDynamicBackgroundStyle()}
className="relative isolate z-10 space-y-4 p-4 border-2 border-emerald-500/40 rounded-2xl w-[350px] shadow-2xl"
style={{ ...getDynamicBackgroundStyle(), minHeight: activeDispH }}
>
<RenderPlacedLogo config={logoConfig} />

Expand Down Expand Up @@ -2655,17 +2705,39 @@ export function App() {
</div>
</SortableContext>
</DndContext>
<div className="pointer-events-none absolute inset-0 z-20" data-testid="infographic-layers">
{currentLayers.filter(l => l.type !== 'background').map(l => (
<div key={l.id} className="pointer-events-auto">
<DynamicLayerRenderer
layer={l}
otherLayers={currentLayers}
isSelected={activeLayerId === l.id}
onSelect={() => setActiveLayerId(l.id)}
onUpdateLayer={handleUpdateLayer}
onUpdateLayerData={handleUpdateLayerData}
onUpdatePosition={handleUpdateLayerPosition}
onOpenContextMenu={handleOpenContextMenuAt}
isInteractive={true}
canvasWidth={activeDispW}
canvasHeight={activeDispH}
enableMagneticSnap={enableMagneticSnap}
gridSnapSize={gridSnapSize}
onDragStateChange={setDraggingGuide}
/>
</div>
))}
</div>
</div>
</CanvasRulers>
)}

</div>

{/* 2. RIGHT SIDEBAR: UNIFIED PHOTOSHOP STUDIO DOCK */}
<div className="space-y-3">
<div className="space-y-3 min-w-0">

{/* Photoshop Studio Dock Tab Navigation Bar */}
<div className="bg-slate-900 border border-slate-800 p-1.5 rounded-2xl flex flex-nowrap items-center gap-1 overflow-x-auto custom-scrollbar shadow-xl" role="group" aria-label="پنل‌های استودیو">
<div className="w-full min-w-0 bg-slate-900 border border-slate-800 p-1.5 rounded-2xl flex flex-wrap items-center gap-1 shadow-xl" role="group" aria-label="پنل‌های استودیو">
{[
{ id: 'layers', label: 'لایه‌ها', icon: Layers, color: 'text-amber-400' },
{ id: 'mockup', label: 'موکاپ‌ها', icon: Smartphone, color: 'text-indigo-400' },
Expand Down Expand Up @@ -2851,7 +2923,7 @@ export function App() {
{sidebarActiveTab === 'chart' && (
<div className="p-4 bg-slate-900 border border-emerald-500/30 rounded-3xl space-y-3 shadow-xl animate-in fade-in duration-150">
<Suspense fallback={<p className="text-xs text-slate-400">در حال بارگذاری نمودارساز…</p>}>
<ProductAnalyticsChart />
<ProductAnalyticsChart onAddToCanvas={handleAddChartLayer} />
</Suspense>
</div>
)}
Expand Down
31 changes: 30 additions & 1 deletion src/components/DynamicLayerRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { toPersianDigits } from '../utils/persianNumbers';
import { sanitizeSvg } from '../utils/sanitizeSvg';
import { fillTemplate, selectVars, useTemplateVars } from '../features/batch/template';

const ProductAnalyticsChartView = React.lazy(() => import('./ProductAnalyticsChartView'));

interface DynamicLayerRendererProps {
layer: CanvasLayerItem;
otherLayers?: CanvasLayerItem[];
Expand Down Expand Up @@ -1224,7 +1226,34 @@ export const DynamicLayerRenderer: React.FC<DynamicLayerRendererProps> = ({
);
}

// 12. MOCKUP LAYER
// 12. PRODUCT ANALYTICS CHART
case 'chart': {
const width = Math.max(160, Number(data.width) || 340);
const height = Math.max(120, Number(data.height) || 230);
const chartTitle = data.chartTitle || layer.name || 'نمودار تحلیل محصول';

return (
<div
className="flex flex-col overflow-hidden rounded-xl border border-emerald-500/40 bg-slate-950 p-2"
style={{ width, height }}
role="img"
aria-label={chartTitle}
>
<h3 className="mb-1 shrink-0 truncate text-right text-xs font-bold text-white">{chartTitle}</h3>
<div className="min-h-0 min-w-0 flex-1">
<React.Suspense fallback={<div className="h-full w-full bg-slate-900" />}>
<ProductAnalyticsChartView
chartType={data.chartType || 'area'}
data={data.chartData || []}
accentColor={data.chartAccentColor || '#10b981'}
/>
</React.Suspense>
</div>
</div>
);
}

// 13. MOCKUP LAYER
case 'mockup': {
const mockupType = data.mockupType || 'mobile';
const screenImage = data.screenImage;
Expand Down
Loading
Loading