diff --git a/e2e/studio.spec.ts b/e2e/studio.spec.ts index a699725..7e0b480 100644 --- a/e2e/studio.spec.ts +++ b/e2e/studio.spec.ts @@ -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( @@ -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')); @@ -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, @@ -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); +}); diff --git a/package-lock.json b/package-lock.json index c8f37c4..0077194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kityar", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kityar", - "version": "1.0.2", + "version": "1.0.4", "license": "UNLICENSED", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 1588427..62cfb54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kityar", "productName": "KitYar", - "version": "1.0.2", + "version": "1.0.4", "description": "کیت یار — دستیار طراحی هوشمند و اتوماسیون کاور محصولات برای مارکت‌پلیس‌های ایرانی", "author": "EHSANKiNG", "license": "UNLICENSED", diff --git a/src/App.tsx b/src/App.tsx index eca81b2..e601bb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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; @@ -594,6 +595,37 @@ export function App() { setTimeout(() => setToastMessage(null), 3000); }; + const handleAddChartLayer = (chartConfig: Partial) => { + 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) => { const newId = `img-${Date.now()}`; const newLayer: CanvasLayerItem = { @@ -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'); @@ -1664,6 +1697,23 @@ export function App() { > +
+ {canvasLayers.infographic.filter(l => l.type !== 'background').map(l => ( + {}} + onUpdateLayer={() => {}} + onUpdateLayerData={() => {}} + onUpdatePosition={() => {}} + isInteractive={false} + canvasWidth={350} + canvasHeight={exportInfographicDisplay.displayHeight} + /> + ))} +
اینفوگرافی رسمی محصول

{productName}

@@ -2092,7 +2142,7 @@ export function App() { {/* 1. LEFT / CENTER: INTERACTIVE CANVAS WITH LIVE LAYERS */}
setActiveLayerId(null)} onContextMenu={(e) => handleOpenContextMenu(e)} > @@ -2547,7 +2597,7 @@ export function App() { {activeAsset === 'infographic' && (
@@ -2655,6 +2705,28 @@ export function App() {
+
+ {currentLayers.filter(l => l.type !== 'background').map(l => ( +
+ setActiveLayerId(l.id)} + onUpdateLayer={handleUpdateLayer} + onUpdateLayerData={handleUpdateLayerData} + onUpdatePosition={handleUpdateLayerPosition} + onOpenContextMenu={handleOpenContextMenuAt} + isInteractive={true} + canvasWidth={activeDispW} + canvasHeight={activeDispH} + enableMagneticSnap={enableMagneticSnap} + gridSnapSize={gridSnapSize} + onDragStateChange={setDraggingGuide} + /> +
+ ))} +
)} @@ -2662,10 +2734,10 @@ export function App() {
{/* 2. RIGHT SIDEBAR: UNIFIED PHOTOSHOP STUDIO DOCK */} -
+
{/* Photoshop Studio Dock Tab Navigation Bar */} -
+
{[ { id: 'layers', label: 'لایه‌ها', icon: Layers, color: 'text-amber-400' }, { id: 'mockup', label: 'موکاپ‌ها', icon: Smartphone, color: 'text-indigo-400' }, @@ -2851,7 +2923,7 @@ export function App() { {sidebarActiveTab === 'chart' && (
در حال بارگذاری نمودارساز…

}> - +
)} diff --git a/src/components/DynamicLayerRenderer.tsx b/src/components/DynamicLayerRenderer.tsx index e7346fd..d71362e 100644 --- a/src/components/DynamicLayerRenderer.tsx +++ b/src/components/DynamicLayerRenderer.tsx @@ -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[]; @@ -1224,7 +1226,34 @@ export const DynamicLayerRenderer: React.FC = ({ ); } - // 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 ( +
+

{chartTitle}

+
+ }> + + +
+
+ ); + } + + // 13. MOCKUP LAYER case 'mockup': { const mockupType = data.mockupType || 'mobile'; const screenImage = data.screenImage; diff --git a/src/components/ProductAnalyticsChart.tsx b/src/components/ProductAnalyticsChart.tsx index 2624602..b1e1cd4 100644 --- a/src/components/ProductAnalyticsChart.tsx +++ b/src/components/ProductAnalyticsChart.tsx @@ -1,22 +1,7 @@ import React, { useState } from 'react'; -import { - ResponsiveContainer, - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - AreaChart, - Area, - RadarChart, - PolarGrid, - PolarAngleAxis, - PolarRadiusAxis, - Radar, - CartesianGrid, -} from 'recharts'; -import { BarChart3, LineChart, PieChart, Sparkles, SlidersHorizontal, Plus, Trash2 } from 'lucide-react'; +import { BarChart3, LineChart, PieChart, Sparkles, SlidersHorizontal, Plus, Trash2, PlusCircle } from 'lucide-react'; +import type { CanvasLayerItem } from '../types/canvasLayers'; +import ProductAnalyticsChartView from './ProductAnalyticsChartView'; export interface DataPoint { name: string; @@ -34,7 +19,11 @@ const DEFAULT_DATA: DataPoint[] = [ { name: 'شهریور', sales: 1450, rating: 98, performance: 97 }, ]; -export const ProductAnalyticsChart: React.FC = () => { +interface ProductAnalyticsChartProps { + onAddToCanvas?: (layer: Partial) => void; +} + +export const ProductAnalyticsChart: React.FC = ({ onAddToCanvas }) => { const [chartType, setChartType] = useState<'area' | 'bar' | 'radar'>('area'); const [data, setData] = useState(DEFAULT_DATA); const [accentColor, setAccentColor] = useState('#10b981'); // Emerald default @@ -127,64 +116,33 @@ export const ProductAnalyticsChart: React.FC = () => { عنکبوتی (Radar)
+ + {onAddToCanvas && ( + + )}
{/* Chart Canvas Display */}
- - {chartType === 'area' ? ( - - - - - - - - - - - - - - - ) : chartType === 'bar' ? ( - - - - - - - - - - ) : ( - - - - - - - - - - )} - +
{/* Editor & Data Controls */} diff --git a/src/components/ProductAnalyticsChartView.tsx b/src/components/ProductAnalyticsChartView.tsx new file mode 100644 index 0000000..8a4606f --- /dev/null +++ b/src/components/ProductAnalyticsChartView.tsx @@ -0,0 +1,92 @@ +import React, { useId } from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { DataPoint } from './ProductAnalyticsChart'; + +interface ProductAnalyticsChartViewProps { + chartType: 'area' | 'bar' | 'radar'; + data: DataPoint[]; + accentColor: string; +} + +export const ProductAnalyticsChartView: React.FC = ({ + chartType, + data, + accentColor, +}) => { + const gradientId = `sales-gradient-${useId().replace(/:/g, '')}`; + const tooltipStyle = { + backgroundColor: '#0f172a', + borderColor: '#334155', + borderRadius: '12px', + color: '#fff', + fontSize: '11px', + }; + const legendStyle = { fontSize: '11px', color: '#cbd5e1' }; + + return ( + + {chartType === 'area' ? ( + + + + + + + + + + + + + + + ) : chartType === 'bar' ? ( + + + + + + + + + + ) : ( + + + + + + + + + + )} + + ); +}; + +export default ProductAnalyticsChartView; diff --git a/src/utils/infographicHeight.test.ts b/src/utils/infographicHeight.test.ts new file mode 100644 index 0000000..138cf46 --- /dev/null +++ b/src/utils/infographicHeight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { getInfographicDisplayHeight } from './infographicHeight'; +import type { CanvasLayerItem } from '../types/canvasLayers'; + +const layer = (y: number, height: number, extra: Partial = {}): CanvasLayerItem => ({ + id: 'l', name: 'l', type: 'text', visible: true, locked: false, zIndex: 1, + scale: 1, rotation: 0, opacity: 100, x: 0, y, data: { height }, ...extra, +}); + +describe('getInfographicDisplayHeight', () => { + it('keeps the base height without layers', () => { + expect(getInfographicDisplayHeight([])).toBe(430); + expect(getInfographicDisplayHeight(undefined)).toBe(430); + }); + it('grows below the lowest layer', () => { + expect(getInfographicDisplayHeight([layer(600, 100)])).toBe(820); + }); + it('ignores hidden and background layers and honours scale', () => { + expect(getInfographicDisplayHeight([layer(900, 100, { visible: false }), layer(900, 100, { type: 'background' })])).toBe(430); + expect(getInfographicDisplayHeight([layer(500, 100, { scale: 2 })])).toBe(820); + }); + it('caps at maxHeight', () => { + expect(getInfographicDisplayHeight([layer(9000, 100)], 430, 3000)).toBe(3000); + }); +}); diff --git a/src/utils/infographicHeight.ts b/src/utils/infographicHeight.ts new file mode 100644 index 0000000..10390e3 --- /dev/null +++ b/src/utils/infographicHeight.ts @@ -0,0 +1,21 @@ +import type { CanvasLayerItem } from '../types/canvasLayers'; + +export const INFOGRAPHIC_BASE_HEIGHT = 430; +export const INFOGRAPHIC_TAIL_PADDING = 120; +const FALLBACK_LAYER_HEIGHT = 120; + +/** Display height of the infographic canvas: grows with the lowest layer. */ +export function getInfographicDisplayHeight( + layers: CanvasLayerItem[] | undefined, + baseHeight: number = INFOGRAPHIC_BASE_HEIGHT, + maxHeight = Infinity, +): number { + let bottom = 0; + for (const layer of layers ?? []) { + if (layer.type === 'background' || !layer.visible) continue; + const h = (layer.data?.height ?? FALLBACK_LAYER_HEIGHT) * (layer.scale || 1); + bottom = Math.max(bottom, layer.y + h); + } + const needed = bottom > 0 ? Math.ceil(bottom + INFOGRAPHIC_TAIL_PADDING) : 0; + return Math.min(maxHeight, Math.max(baseHeight, needed)); +}