diff --git a/packages/plugin-gantt/demo/main.tsx b/packages/plugin-gantt/demo/main.tsx index 84e556869a..993b42b2ef 100644 --- a/packages/plugin-gantt/demo/main.tsx +++ b/packages/plugin-gantt/demo/main.tsx @@ -32,9 +32,11 @@ const GANTT_ZH = { jumpToToday: '跳到今天', today: '今天', showTaskList: '显示任务列表', hideTaskList: '隐藏任务列表', viewMode: '时间粒度', enterFullscreen: '进入全屏', exitFullscreen: '退出全屏', criticalPath: '高亮关键路径', autoSchedule: '自动排程依赖', exportPng: '导出 PNG', + exportPdf: '导出 PDF', saveLayout: '保存布局', + thisWeek: '本周', thisMonth: '本月', undo: '撤销', redo: '重做', }, - viewMode: { day: '日', week: '周', month: '月', quarter: '季' }, + viewMode: { day: '日', week: '周', month: '月', quarter: '季', year: '年' }, row: { expand: '展开', collapse: '折叠' }, aria: { taskList: '任务列表' }, tooltip: { days: '天' }, @@ -362,7 +364,9 @@ function App() { ) : ( console.log('[gantt-demo] layout saved', l)} inlineEdit onTaskClick={(t) => console.log('[gantt-demo] click', t.id)} onTaskUpdate={(t, changes) => patch(t.id, changes)} diff --git a/packages/plugin-gantt/docs/verification/45-year-granularity.png b/packages/plugin-gantt/docs/verification/45-year-granularity.png new file mode 100644 index 0000000000..b3ee34eb60 Binary files /dev/null and b/packages/plugin-gantt/docs/verification/45-year-granularity.png differ diff --git a/packages/plugin-gantt/docs/verification/46-navigation.png b/packages/plugin-gantt/docs/verification/46-navigation.png new file mode 100644 index 0000000000..d663c17957 Binary files /dev/null and b/packages/plugin-gantt/docs/verification/46-navigation.png differ diff --git a/packages/plugin-gantt/docs/verification/47-save-layout.png b/packages/plugin-gantt/docs/verification/47-save-layout.png new file mode 100644 index 0000000000..58c7e15ad1 Binary files /dev/null and b/packages/plugin-gantt/docs/verification/47-save-layout.png differ diff --git a/packages/plugin-gantt/docs/verification/48-layout-restored.png b/packages/plugin-gantt/docs/verification/48-layout-restored.png new file mode 100644 index 0000000000..5c982128eb Binary files /dev/null and b/packages/plugin-gantt/docs/verification/48-layout-restored.png differ diff --git a/packages/plugin-gantt/docs/verification/README.md b/packages/plugin-gantt/docs/verification/README.md index 00b1d18b82..e14bb3b1fc 100644 --- a/packages/plugin-gantt/docs/verification/README.md +++ b/packages/plugin-gantt/docs/verification/README.md @@ -526,3 +526,30 @@ The script asserts (5/5 checks passed): topological forward pass (FS/SS/FF/SF aware, summaries fixed), pushing t4 back to satisfy the link. - ![Auto-rescheduled](44-conflict-rescheduled.png) + +## Navigation + year scale + save layout + PNG/PDF export — 导航 + 年刻度 + 保存布局 + 导出 + +`scripts/verify-export-layout.mjs` drives the `?lang=zh` project fixture and +exercises the Group 3 toolbar additions. Covered by unit tests in +[`GanttView.layout.test.tsx`](../../src/GanttView.layout.test.tsx) (12 cases) +and [`ObjectGantt.test.tsx`](../../src/ObjectGantt.test.tsx) (persistLayoutKey +wiring). + +The script asserts (7/7 checks passed): + +- **年刻度** — a new 年 granularity button widens the timeline to one column + per year, with a `2020s` decade band above each year header. +- ![Year granularity](45-year-granularity.png) +- **导航** — 本周 / 本月 buttons scroll the timeline to the start of the current + week / month (alongside the existing 今天 jump). +- ![Navigation](46-navigation.png) +- **保存布局** — the 保存布局 button snapshots the current granularity + zoom + + list-collapse to `localStorage` (key `gantt-layout::`) and fires + `onLayoutChange`; the button briefly highlights to confirm. +- ![Save layout](47-save-layout.png) +- **持久化** — reloading without a `?mode=` override restores the saved 月 + granularity from `localStorage`. +- ![Layout restored](48-layout-restored.png) +- **导出 PNG / PDF** — both export buttons download real files: a valid PNG + (`‰PNG` magic bytes) and a dependency-free single-page PDF (`%PDF-` header) + embedding the rasterized chart as a JPEG via `DCTDecode`. diff --git a/packages/plugin-gantt/scripts/verify-export-layout.mjs b/packages/plugin-gantt/scripts/verify-export-layout.mjs new file mode 100644 index 0000000000..d216874436 --- /dev/null +++ b/packages/plugin-gantt/scripts/verify-export-layout.mjs @@ -0,0 +1,108 @@ +/** + * 导航 + 年刻度 + 保存布局 + 导出 PNG/PDF verification (Group 3). + * + * Drives the demo (?lang=zh) project fixture and asserts: + * 1. the 年 (year) granularity button switches the timeline (年刻度), + * 2. 本周 / 本月 navigation buttons scroll the timeline, + * 3. 保存布局 persists granularity to localStorage and survives a reload, + * 4. 导出 PNG / 导出 PDF download files with the right extensions/magic bytes. + * Persists screenshots 45-48 under docs/verification/. + * + * GANTT_DEMO_URL=http://localhost:5200 node packages/plugin-gantt/scripts/verify-export-layout.mjs + */ +import { chromium } from 'playwright'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +const EXEC = + '/Users/baozhoutao/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell'; +const BASE = process.env.GANTT_DEMO_URL || 'http://localhost:5199'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT = path.resolve(__dirname, '../docs/verification'); +const DL = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-dl-')); + +const browser = await chromium.launch({ executablePath: EXEC }); +const ctx = await browser.newContext({ viewport: { width: 1500, height: 900 }, acceptDownloads: true }); +const page = await ctx.newPage(); +const fails = []; +const ok = (cond, msg, detail = '') => { + if (!cond) fails.push(msg); + console.log(`${cond ? '✓' : '✗'} ${msg}${detail ? ` — ${detail}` : ''}`); +}; +const pressed = (testid) => + page.$eval(`[data-testid="${testid}"]`, (el) => el.getAttribute('aria-pressed')).catch(() => null); + +const saveDownload = async (triggerSel) => { + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 8000 }), + page.click(triggerSel), + ]); + const name = download.suggestedFilename(); + const dest = path.join(DL, name); + await download.saveAs(dest); + return { name, bytes: fs.readFileSync(dest) }; +}; + +try { + await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' }); + await page.waitForSelector('[data-testid="gantt-view-mode-year"]', { timeout: 8000 }); + + // 1) 年刻度 — switch to the year granularity. + await page.click('[data-testid="gantt-view-mode-year"]'); + await page.waitForTimeout(300); + ok((await pressed('gantt-view-mode-year')) === 'true', '年 granularity button activates (年刻度)'); + await page.screenshot({ path: path.join(OUT, '45-year-granularity.png') }); + + // back to a finer mode for the nav test + await page.click('[data-testid="gantt-view-mode-month"]'); + await page.waitForTimeout(200); + + // 2) Navigation — 本周 / 本月 scroll the timeline. + const scrollOf = () => + page.$eval('[data-testid="gantt-timeline"]', (el) => el.scrollLeft).catch(() => null); + await page.click('[data-testid="gantt-jump-month"]'); + await page.waitForTimeout(200); + const afterMonth = await scrollOf(); + ok(afterMonth !== null && Number.isFinite(afterMonth), '本月 navigation scrolls the timeline', `scrollLeft=${afterMonth}`); + await page.click('[data-testid="gantt-jump-week"]'); + await page.waitForTimeout(200); + ok(await page.$('[data-testid="gantt-jump-week"]'), '本周 navigation button present & clickable'); + await page.screenshot({ path: path.join(OUT, '46-navigation.png') }); + + // 3) 保存布局 — set month, save, reload, expect month restored. + await page.click('[data-testid="gantt-view-mode-month"]'); + await page.waitForTimeout(150); + await page.click('[data-testid="gantt-save-layout"]'); + await page.waitForTimeout(150); + ok((await pressed('gantt-save-layout')) === 'true', '保存布局 button reflects a save (aria-pressed)'); + await page.screenshot({ path: path.join(OUT, '47-save-layout.png') }); + + // Reload WITHOUT a ?mode= override so the persisted layout wins. + await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' }); + await page.waitForSelector('[data-testid="gantt-view-mode-month"]', { timeout: 8000 }); + await page.waitForTimeout(300); + ok((await pressed('gantt-view-mode-month')) === 'true', '保存布局 restores 月 granularity after reload (持久化)'); + await page.screenshot({ path: path.join(OUT, '48-layout-restored.png') }); + + // 4) 导出 PNG / PDF — verify real downloads with correct magic bytes. + const png = await saveDownload('[data-testid="gantt-export-png"]'); + const pngMagic = png.bytes[0] === 0x89 && png.bytes[1] === 0x50 && png.bytes[2] === 0x4e && png.bytes[3] === 0x47; + ok(png.name.endsWith('.png') && pngMagic, '导出 PNG downloads a valid PNG', `${png.name} ${png.bytes.length}B`); + + const pdf = await saveDownload('[data-testid="gantt-export-pdf"]'); + const head = pdf.bytes.subarray(0, 5).toString('latin1'); + ok(pdf.name.endsWith('.pdf') && head === '%PDF-', '导出 PDF downloads a valid PDF', `${pdf.name} ${pdf.bytes.length}B head=${head}`); + + console.log(`\nscreenshots → ${OUT} (45–48)`); +} catch (err) { + console.error(err); + fails.push(String(err)); +} finally { + await browser.close(); + fs.rmSync(DL, { recursive: true, force: true }); +} + +if (fails.length) { console.error(`\n${fails.length} check(s) failed`); process.exit(1); } +console.log('\nall checks passed'); diff --git a/packages/plugin-gantt/src/GanttView.layout.test.tsx b/packages/plugin-gantt/src/GanttView.layout.test.tsx new file mode 100644 index 0000000000..08d0f29542 --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.layout.test.tsx @@ -0,0 +1,139 @@ +/** + * Group 3 tests: navigation buttons, year granularity, layout persistence + * (保存布局), and the PNG / PDF export toolbar buttons. + * + * Conventions match the other interaction tests: innerWidth=1280 → + * columnWidth 60, rowHeight 40. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { GanttView, type GanttTask, type GanttLayout } from './GanttView'; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); + window.localStorage.clear(); +}); + +function makeTask(id: string, start: string, end: string, extra: Partial = {}): GanttTask { + return { id, title: `Task ${id}`, start: new Date(start), end: new Date(end), progress: 0, ...extra }; +} + +const TASKS = () => [ + makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }), + makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z'), +]; + +function renderView(props: Partial> = {}) { + return render( +
+ +
+ ); +} + +describe('GanttView navigation buttons (导航)', () => { + it('renders jump-to-today / this-week / this-month controls', () => { + const { getByTestId } = renderView(); + expect(getByTestId('gantt-jump-today')).toBeTruthy(); + expect(getByTestId('gantt-jump-week')).toBeTruthy(); + expect(getByTestId('gantt-jump-month')).toBeTruthy(); + }); + + it('this-week / this-month scroll the timeline horizontally', () => { + const { getByTestId } = renderView(); + const timeline = getByTestId('gantt-timeline') as HTMLElement; + // jsdom has no layout, so scrollLeft is a plain settable number; assert the + // handlers run without throwing and leave a finite scrollLeft. + act(() => { fireEvent.click(getByTestId('gantt-jump-week')); }); + expect(Number.isFinite(timeline.scrollLeft)).toBe(true); + act(() => { fireEvent.click(getByTestId('gantt-jump-month')); }); + expect(Number.isFinite(timeline.scrollLeft)).toBe(true); + }); +}); + +describe('GanttView year granularity (年刻度)', () => { + it('exposes a year view-mode button and switches to it', () => { + const { getByTestId } = renderView(); + const yearBtn = getByTestId('gantt-view-mode-year'); + expect(yearBtn).toBeTruthy(); + act(() => { fireEvent.click(yearBtn); }); + expect(yearBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('seeds the year granularity from the viewMode prop', () => { + const { getByTestId } = renderView({ viewMode: 'year' }); + expect(getByTestId('gantt-view-mode-year').getAttribute('aria-pressed')).toBe('true'); + }); +}); + +describe('GanttView export buttons (导出 PNG / PDF)', () => { + it('renders both the PNG and PDF export buttons', () => { + const { getByTestId } = renderView(); + expect(getByTestId('gantt-export-png')).toBeTruthy(); + expect(getByTestId('gantt-export-pdf')).toBeTruthy(); + }); +}); + +describe('GanttView save layout (保存布局)', () => { + it('hides the save-layout button without persistLayoutKey/onLayoutChange', () => { + const { queryByTestId } = renderView(); + expect(queryByTestId('gantt-save-layout')).toBeNull(); + }); + + it('shows the save-layout button when onLayoutChange is set', () => { + const { getByTestId } = renderView({ onLayoutChange: () => {} }); + expect(getByTestId('gantt-save-layout')).toBeTruthy(); + }); + + it('persists the current layout to localStorage under the key', () => { + const { getByTestId } = renderView({ persistLayoutKey: 'proj1' }); + act(() => { fireEvent.click(getByTestId('gantt-view-mode-month')); }); + act(() => { fireEvent.click(getByTestId('gantt-save-layout')); }); + const raw = window.localStorage.getItem('gantt-layout:proj1'); + expect(raw).toBeTruthy(); + const saved = JSON.parse(raw!) as GanttLayout; + expect(saved.viewMode).toBe('month'); + expect(saved.taskListCollapsed).toBe(false); + }); + + it('calls onLayoutChange with the snapshot on save', () => { + const onLayoutChange = vi.fn(); + const { getByTestId } = renderView({ onLayoutChange }); + act(() => { fireEvent.click(getByTestId('gantt-view-mode-quarter')); }); + act(() => { fireEvent.click(getByTestId('gantt-save-layout')); }); + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange.mock.calls[0][0].viewMode).toBe('quarter'); + }); + + it('restores a persisted granularity on mount', () => { + window.localStorage.setItem( + 'gantt-layout:proj2', + JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout) + ); + const { getByTestId } = renderView({ persistLayoutKey: 'proj2' }); + expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('true'); + }); + + it('lets the viewMode prop win over a persisted granularity', () => { + window.localStorage.setItem( + 'gantt-layout:proj3', + JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout) + ); + const { getByTestId } = renderView({ persistLayoutKey: 'proj3', viewMode: 'week' }); + expect(getByTestId('gantt-view-mode-week').getAttribute('aria-pressed')).toBe('true'); + expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('false'); + }); + + it('ignores malformed persisted layout JSON', () => { + window.localStorage.setItem('gantt-layout:proj4', '{not valid json'); + const { getByTestId } = renderView({ persistLayoutKey: 'proj4' }); + // Falls back to the default 'day' granularity without throwing. + expect(getByTestId('gantt-view-mode-day').getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index d96182143b..871e55050c 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -26,6 +26,8 @@ import { Wand2, Undo2, Redo2, + FileDown, + Save, } from "lucide-react" import { cn, @@ -115,9 +117,9 @@ export interface GanttTask { } /** Timeline granularity — one column per day, week, month, or quarter. */ -export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter'; +export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year'; -const VIEW_MODES: GanttViewMode[] = ['day', 'week', 'month', 'quarter']; +const VIEW_MODES: GanttViewMode[] = ['day', 'week', 'month', 'quarter', 'year']; /** * Nominal days represented by one column at each granularity. Sets the zoom @@ -130,6 +132,7 @@ export const NOMINAL_DAYS: Record = { week: 7, month: 30.44, quarter: 91.31, + year: 365.25, }; export const MS_PER_DAY = 1000 * 60 * 60 * 24; @@ -144,6 +147,8 @@ export function startOfUnit(date: Date, mode: GanttViewMode): Date { d.setDate(1); } else if (mode === 'quarter') { d.setMonth(Math.floor(d.getMonth() / 3) * 3, 1); + } else if (mode === 'year') { + d.setMonth(0, 1); } return d; } @@ -169,7 +174,7 @@ export function addUnits(date: Date, units: number, mode: GanttViewMode): Date { } else if (mode === 'week') { d.setDate(d.getDate() + units * 7); } else { - const months = units * (mode === 'month' ? 1 : 3); + const months = units * (mode === 'month' ? 1 : mode === 'quarter' ? 3 : 12); const dayOfMonth = d.getDate(); d.setDate(1); d.setMonth(d.getMonth() + months); @@ -266,6 +271,141 @@ export interface GanttViewProps { groupBy?: (task: GanttTask) => { key: string | number; label: string } | null /** Label for the bucket collecting tasks whose `groupBy` returns null. */ ungroupedLabel?: string + /** + * Persist the user's layout tweaks (granularity + column/task-list widths) + * to `localStorage` under this key. On mount the saved layout is restored; + * the "保存布局" toolbar button writes the current layout. Omit to disable + * persistence. The button still appears when `onLayoutChange` is set. + */ + persistLayoutKey?: string + /** + * Notified when the user saves the current layout (保存布局). Receives the + * snapshot `{ viewMode, columnWidth, taskListCollapsed }`. Use this to persist + * layout in your own store instead of (or alongside) `persistLayoutKey`. + */ + onLayoutChange?: (layout: GanttLayout) => void +} + +/** Persisted layout snapshot written by the "保存布局" toolbar button. */ +export interface GanttLayout { + viewMode: GanttViewMode + /** Effective day-column width in px, or null when auto-fit. */ + columnWidth: number | null + taskListCollapsed: boolean +} + +// --- Export helpers (导出 PNG / PDF) — module-level, no React deps. --- + +/** Rasterize a standalone SVG string to a 2×-scaled canvas (white-backed). */ +function rasterizeSvg(svg: string, W: number, H: number, scale = 2): Promise { + return new Promise((resolve) => { + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = W * scale; + canvas.height = H * scale; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + } + URL.revokeObjectURL(url); + resolve(canvas); + }; + img.onerror = () => { URL.revokeObjectURL(url); resolve(null); }; + img.src = url; + }); +} + +/** Download a Blob under `filename` via a transient anchor. */ +function downloadBlob(blob: Blob, filename: string): void { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +/** + * Build a minimal single-page PDF that embeds a JPEG (DCTDecode) at its native + * pixel size — dependency-free, just enough structure for any PDF viewer. The + * page MediaBox matches the image so it fills the page upright. + */ +function buildJpegPdf(jpeg: Uint8Array, w: number, h: number): Blob { + const enc = (s: string) => { + const a = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i) & 0xff; + return a; + }; + const content = `q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`; + const chunks: Uint8Array[] = []; + const offsets: number[] = []; + let pos = 0; + const push = (u: Uint8Array) => { chunks.push(u); pos += u.length; }; + const mark = () => { offsets.push(pos); }; + + push(enc('%PDF-1.3\n%\xFF\xFF\xFF\xFF\n')); + mark(); push(enc('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n')); + mark(); push(enc('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n')); + mark(); push(enc(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n`)); + mark(); + push(enc(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${w} /Height ${h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`)); + push(jpeg); + push(enc('\nendstream\nendobj\n')); + mark(); push(enc(`5 0 obj\n<< /Length ${content.length} >>\nstream\n${content}\nendstream\nendobj\n`)); + + const xrefPos = pos; + let xref = `xref\n0 6\n0000000000 65535 f \n`; + for (const off of offsets) xref += `${String(off).padStart(10, '0')} 00000 n \n`; + xref += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`; + push(enc(xref)); + + return new Blob(chunks as BlobPart[], { type: 'application/pdf' }); +} + +/** Decode a base64 data-URL payload to bytes. */ +function dataUrlToBytes(dataUrl: string): Uint8Array { + const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); + const bin = atob(base64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** localStorage key namespace for persisted layouts. */ +const LAYOUT_STORAGE_PREFIX = 'gantt-layout:'; + +/** Read a persisted layout, tolerating absent storage / malformed JSON. */ +function readSavedLayout(key: string | undefined): GanttLayout | null { + if (!key || typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(LAYOUT_STORAGE_PREFIX + key); + if (!raw) return null; + const p = JSON.parse(raw) as Partial; + const viewMode = p.viewMode && VIEW_MODES.includes(p.viewMode) ? p.viewMode : null; + if (!viewMode) return null; + const columnWidth = + typeof p.columnWidth === 'number' && isFinite(p.columnWidth) ? p.columnWidth : null; + return { viewMode, columnWidth, taskListCollapsed: !!p.taskListCollapsed }; + } catch { + return null; + } +} + +/** Persist a layout snapshot, swallowing quota/SSR errors. */ +function writeSavedLayout(key: string, layout: GanttLayout): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(LAYOUT_STORAGE_PREFIX + key, JSON.stringify(layout)); + } catch { + /* storage unavailable / full — non-fatal */ + } } export function GanttView({ @@ -291,6 +431,8 @@ export function GanttView({ readOnly = false, groupBy, ungroupedLabel = 'Ungrouped', + persistLayoutKey, + onLayoutChange, }: GanttViewProps) { // Read-only gating, applied once at the top so every downstream usage — // drag/resize/progress, inline edit, delete, link-drag, reorder, @@ -316,13 +458,26 @@ export function GanttView({ const isNarrow = effectiveWidth < 640; const rowHeight = rowHeightForContainer(effectiveWidth); const baseColumnWidth = columnWidthForContainer(effectiveWidth); + // Restore a persisted layout once on first render (when persistLayoutKey set). + // It seeds the initial granularity / zoom / list-collapse below; the prop + // still wins for viewMode if explicitly supplied. + const restoredLayoutRef = React.useRef(undefined); + if (restoredLayoutRef.current === undefined) { + restoredLayoutRef.current = readSavedLayout(persistLayoutKey); + } + const restoredLayout = restoredLayoutRef.current; // Mobile UX (round 3): make zoom + list-collapse stateful so the toolbar // buttons + pinch-to-zoom gesture actually persist. - const [columnWidthOverride, setColumnWidthOverride] = React.useState(null); + const [columnWidthOverride, setColumnWidthOverride] = React.useState( + restoredLayout ? restoredLayout.columnWidth : null + ); // Timeline granularity. The prop seeds (and can later override) the state; - // the toolbar segmented control switches it interactively. + // the toolbar segmented control switches it interactively. A persisted layout + // seeds it when no explicit prop is given. const [viewMode, setViewMode] = React.useState( - viewModeProp && VIEW_MODES.includes(viewModeProp) ? viewModeProp : 'day' + viewModeProp && VIEW_MODES.includes(viewModeProp) + ? viewModeProp + : restoredLayout?.viewMode ?? 'day' ); React.useEffect(() => { if (viewModeProp && VIEW_MODES.includes(viewModeProp)) setViewMode(viewModeProp); @@ -331,7 +486,9 @@ export function GanttView({ setViewMode(mode); onViewChange?.(mode); }, [onViewChange]); - const [taskListCollapsed, setTaskListCollapsed] = React.useState(false); + const [taskListCollapsed, setTaskListCollapsed] = React.useState( + restoredLayout ? restoredLayout.taskListCollapsed : false + ); // Auto-collapse the list once on first narrow render — undoable by the user. const collapsedAutoSet = React.useRef(false); React.useEffect(() => { @@ -1148,8 +1305,10 @@ export function GanttView({ label = current.toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' }); } else if (viewMode === 'month') { label = current.toLocaleDateString(dateLocale, { month: 'short' }); - } else { + } else if (viewMode === 'quarter') { label = `Q${Math.floor(current.getMonth() / 3) + 1}`; + } else { + label = String(current.getFullYear()); } cols.push({ date: new Date(current), @@ -1277,24 +1436,30 @@ export function GanttView({ const foldShiftRef = React.useRef<((date: Date, n: number) => Date) | null>(null); foldShiftRef.current = folding ? shiftByWorkingColumns : null; - // Upper scale row: month groups under day/week, year groups under month/quarter. + // Upper scale row: month groups under day/week, year groups under + // month/quarter, decade groups under year. const headerGroups = React.useMemo(() => { const groups: { key: string; label: string; width: number; offset: number }[] = []; - const byYear = viewMode === 'month' || viewMode === 'quarter'; + const groupBy: 'decade' | 'year' | 'month' = + viewMode === 'year' ? 'decade' : viewMode === 'month' || viewMode === 'quarter' ? 'year' : 'month'; let acc = 0; for (const col of timeColumns) { - const key = byYear - ? String(col.date.getFullYear()) - : `${col.date.getFullYear()}-${col.date.getMonth()}`; + const year = col.date.getFullYear(); + const decade = Math.floor(year / 10) * 10; + const key = + groupBy === 'decade' ? String(decade) : groupBy === 'year' ? String(year) : `${year}-${col.date.getMonth()}`; const last = groups[groups.length - 1]; if (last && last.key === key) { last.width += col.width; } else { groups.push({ key, - label: byYear - ? String(col.date.getFullYear()) - : col.date.toLocaleDateString(dateLocale, { month: 'short', year: 'numeric' }), + label: + groupBy === 'decade' + ? `${decade}s` + : groupBy === 'year' + ? String(year) + : col.date.toLocaleDateString(dateLocale, { month: 'short', year: 'numeric' }), width: col.width, offset: acc, }); @@ -1431,6 +1596,28 @@ export function GanttView({ scrollAreaRef.current.scrollTo({ left: target, behavior: 'smooth' }); }, [todayLeftPx]); + // 导航: scroll the timeline so a given date sits near the left edge. Returns + // false (no-op) when the date is outside the rendered range. + const scrollToDate = React.useCallback( + (date: Date, align: 'left' | 'center' = 'left') => { + const el = scrollAreaRef.current; + if (!el || date < timelineRange.start || date > timelineRange.end) return false; + const x = Math.round(dateToX(date)); + const target = align === 'center' ? x - el.clientWidth / 2 : x - 24; + el.scrollTo({ left: Math.max(0, target), behavior: 'smooth' }); + return true; + }, + [timelineRange, dateToX], + ); + const jumpToWeek = React.useCallback( + () => scrollToDate(startOfUnit(new Date(), 'week')), + [scrollToDate], + ); + const jumpToMonth = React.useCallback( + () => scrollToDate(startOfUnit(new Date(), 'month')), + [scrollToDate], + ); + const handleScroll = (e: React.UIEvent) => { const el = e.currentTarget; // Sync horizontal scroll to header @@ -1681,8 +1868,8 @@ export function GanttView({ // and concrete colors (the prebuilt CSS vars don't resolve in a detached // SVG). Captures the left name column + the timeline bars, links and today // line; critical highlighting is included when the toggle is on. - const exportPng = React.useCallback(() => { - if (typeof document === 'undefined' || !tasks.length) return; + const buildExportSvg = React.useCallback((): { svg: string; W: number; H: number } | null => { + if (typeof document === 'undefined' || !tasks.length) return null; const esc = (s: string) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!)); // Two-row header like the live chart: a month/year group band over the @@ -1787,35 +1974,49 @@ export function GanttView({ parts.push(``); const svg = `${parts.join('')}`; - const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const img = new Image(); - img.onload = () => { - const scale = 2; - const canvas = document.createElement('canvas'); - canvas.width = W * scale; - canvas.height = H * scale; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(scale, scale); - ctx.drawImage(img, 0, 0); - } - URL.revokeObjectURL(url); - canvas.toBlob((png) => { - if (!png) return; - const a = document.createElement('a'); - a.href = URL.createObjectURL(png); - a.download = `gantt-${viewMode}.png`; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - }, 'image/png'); - }; - img.onerror = () => URL.revokeObjectURL(url); - img.src = url; + return { svg, W, H }; }, [tasks, rows, links, linkPath, styleFor, isCriticalTask, critical, timeColumns, colOffsets, totalWidth, taskListWidth, rowHeight, barTop, barHeight, summaryBarTop, summaryBarHeight, milestoneSize, todayLeftPx, viewMode, showBaselines, baselineTop, baselineHeight, BASELINE_FILL, BASELINE_BORDER, resolvedMarkers, headerGroups]); + const exportPng = React.useCallback(async () => { + const built = buildExportSvg(); + if (!built) return; + const canvas = await rasterizeSvg(built.svg, built.W, built.H); + if (!canvas) return; + canvas.toBlob((png) => { if (png) downloadBlob(png, `gantt-${viewMode}.png`); }, 'image/png'); + }, [buildExportSvg, viewMode]); + + const exportPdf = React.useCallback(async () => { + const built = buildExportSvg(); + if (!built) return; + const canvas = await rasterizeSvg(built.svg, built.W, built.H); + if (!canvas) return; + // JPEG keeps the embedded image small and embeds directly via DCTDecode. + const jpeg = dataUrlToBytes(canvas.toDataURL('image/jpeg', 0.92)); + const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height); + downloadBlob(pdf, `gantt-${viewMode}.pdf`); + }, [buildExportSvg, viewMode]); + + // Snapshot the current layout (granularity + zoom + list state), persist it + // under persistLayoutKey, and notify onLayoutChange. The persisted columnWidth + // is the manual override (null = auto-fit), so a saved auto-fit stays adaptive. + const [layoutSaved, setLayoutSaved] = React.useState(false); + const saveLayout = React.useCallback(() => { + const layout: GanttLayout = { + viewMode, + columnWidth: columnWidthOverride, + taskListCollapsed, + }; + if (persistLayoutKey) writeSavedLayout(persistLayoutKey, layout); + onLayoutChange?.(layout); + setLayoutSaved(true); + }, [viewMode, columnWidthOverride, taskListCollapsed, persistLayoutKey, onLayoutChange]); + // Briefly reflect a save in the button's aria-pressed for feedback/testability. + React.useEffect(() => { + if (!layoutSaved) return; + const id = setTimeout(() => setLayoutSaved(false), 1500); + return () => clearTimeout(id); + }, [layoutSaved]); + return (
{/* Hover and responsive rules the prebuilt components CSS can't provide @@ -1932,6 +2133,26 @@ export function GanttView({ > + + {onTaskUpdate ? ( <> + + {onLayoutChange || persistLayoutKey ? ( + + ) : null}