diff --git a/README.md b/README.md index 051fd0e..37f3763 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ npm install npm run dev ``` +Прямые web-пути отдельных приложений: `/qword`, `/qexcell`, `/qpowerpoint`, `/qoutlook`. + ## Desktop-оболочка ```bash @@ -32,7 +34,7 @@ npm run build ## Поддержка форматов - QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. -- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. +- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка. - QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc`, `Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. diff --git a/src/App.tsx b/src/App.tsx index 3281f24..db8afb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,9 +6,21 @@ import { QWord } from "./features/qword/QWord"; import { QExcell } from "./features/qexcell/QExcell"; import { initialState } from "./data/sampleData"; import { usePersistentState } from "./hooks/usePersistentState"; -import type { AppId, MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types"; +import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types"; const STORAGE_KEY = "qoffice-state-v1"; +const appPaths = { + "/": "word", + "/qword": "word", + "/qexcell": "sheets", + "/qpowerpoint": "slides", + "/qoutlook": "mail" +} as const; + +function activeAppFromPath(pathname: string) { + const normalizedPath = pathname.toLowerCase().replace(/\/$/, "") || "/"; + return appPaths[normalizedPath as keyof typeof appPaths] ?? "word"; +} function migrateQOfficeState(state: QOfficeState): QOfficeState { const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments; @@ -91,14 +103,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState { export function App() { const [state, setState] = usePersistentState(STORAGE_KEY, initialState, migrateQOfficeState); + const activeApp = activeAppFromPath(window.location.pathname); const status = useMemo(() => { const updatedAt = - state.activeApp === "word" + activeApp === "word" ? state.word.updatedAt - : state.activeApp === "sheets" + : activeApp === "sheets" ? state.workbook.updatedAt - : state.activeApp === "slides" + : activeApp === "slides" ? state.deck.updatedAt : undefined; @@ -106,11 +119,7 @@ export function App() { savedLabel: "Сохранено локально", updatedAt }; - }, [state]); - - function setActiveApp(activeApp: AppId) { - setState((current) => ({ ...current, activeApp })); - } + }, [activeApp, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt]); function updateWord(word: WordDocument) { setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } })); @@ -140,18 +149,18 @@ export function App() { } const activeSurface = - state.activeApp === "word" ? ( + activeApp === "word" ? ( - ) : state.activeApp === "sheets" ? ( + ) : activeApp === "sheets" ? ( - ) : state.activeApp === "slides" ? ( + ) : activeApp === "slides" ? ( ) : ( ); return ( - + {activeSurface} ); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 7b50506..5871b28 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,23 +1,16 @@ import { - BarChart3, - FileText, - HelpCircle, - Mail, - MonitorCog, - PanelRight, - Presentation, + ChevronDown, + Redo2, Save, Search, - Settings, Share2, - Table2 + Undo2 } from "lucide-react"; import type { ReactNode } from "react"; import type { AppId } from "../types"; interface AppShellProps { activeApp: AppId; - onAppChange: (appId: AppId) => void; status: { savedLabel: string; updatedAt?: string; @@ -25,85 +18,37 @@ interface AppShellProps { children: ReactNode; } -const apps: Array<{ id: AppId; label: string; icon: typeof FileText; accent: string }> = [ - { id: "word", label: "QWord", icon: FileText, accent: "word" }, - { id: "sheets", label: "QExcell", icon: Table2, accent: "sheets" }, - { id: "slides", label: "QPowerPoint", icon: Presentation, accent: "slides" }, - { id: "mail", label: "QOutlook", icon: Mail, accent: "mail" } -]; - -const ribbonGroups = [ - { - title: "Буфер", - commands: [ - { label: "Вставить", icon: FileText }, - { label: "Копировать", icon: Save } - ] - }, - { - title: "Вставка", - commands: [ - { label: "Таблица", icon: Table2 }, - { label: "Диаграмма", icon: BarChart3 }, - { label: "Панель", icon: PanelRight } - ] - }, - { - title: "Совместная работа", - commands: [ - { label: "Поделиться", icon: Share2 }, - { label: "Параметры", icon: MonitorCog } - ] - } -]; - -export function AppShell({ activeApp, onAppChange, status, children }: AppShellProps) { - const active = apps.find((app) => app.id === activeApp) ?? apps[0]; +const appTitles: Record = { + word: "Документ1 - QWord", + sheets: "Книга1 - QExcell", + slides: "Презентация1 - QPowerPoint", + mail: "Почта - QOutlook" +}; +export function AppShell({ activeApp, status, children }: AppShellProps) { return (
- -
-
- {active.label} - {status.savedLabel} +
+ + + +
+ {appTitles[activeApp]}
))}
-
- {ribbonGroups.map((group) => ( -
-
- {group.commands.map((command) => { - const Icon = command.icon; - return ( - - ); - })} -
- {group.title} -
- ))} -
{children}
diff --git a/src/features/qexcell/QExcell.tsx b/src/features/qexcell/QExcell.tsx index 9ca5121..21cd4ee 100644 --- a/src/features/qexcell/QExcell.tsx +++ b/src/features/qexcell/QExcell.tsx @@ -11,6 +11,8 @@ import { Bold, Download, Eraser, + Eye, + EyeOff, FileText, FileUp, FunctionSquare, @@ -30,7 +32,7 @@ import { WrapText } from "lucide-react"; import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types"; -import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet"; +import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet"; import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice"; import { replaceFileExtension } from "../../io/fileHelpers"; @@ -47,6 +49,7 @@ const extraEditableRows = 1; const extraEditableColumns = 1; const emptyHyperlinks: Record = {}; const emptyCellFormats: Record = {}; +const emptyHiddenItems: Record = {}; const emptyCharts: SheetChart[] = []; const fillSwatches = [ @@ -224,6 +227,23 @@ function normalizedPrintAreaInput(value: string) { return [...new Set(ranges)].join(","); } +function nextHiddenItems(items: Record, key: string, hidden: boolean) { + const nextItems = Object.entries(items).reduce>((result, [itemKey, isHidden]) => { + if (isHidden) { + result[itemKey] = true; + } + return result; + }, {}); + + if (hidden) { + nextItems[key] = true; + } else { + delete nextItems[key]; + } + + return Object.keys(nextItems).length > 0 ? nextItems : undefined; +} + function chartTypeIcon(type: SheetChartType) { if (type === "line") { return LineChart; @@ -250,11 +270,19 @@ export function QExcell({ workbook, onChange }: QExcellProps) { const activeMergedCells = activeSheet.mergedCells ?? []; const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks; const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats; + const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems; + const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems; const activeCharts = activeSheet.charts ?? emptyCharts; const selectedRawValue = activeSheet.cells[selectedCell] ?? ""; const selectedHyperlink = activeHyperlinks[selectedCell] ?? ""; const selectedCellFormat = activeCellFormats[selectedCell] ?? {}; - const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat); + const selectedCellPosition = parseCellId(selectedCell) ?? { column: 0, row: 0 }; + const selectedColumnLabel = columnLabel(selectedCellPosition.column); + const selectedRowLabel = String(selectedCellPosition.row + 1); + const selectedColumnHidden = Boolean(activeHiddenColumns[selectedColumnLabel]); + const selectedRowHidden = Boolean(activeHiddenRows[selectedRowLabel]); + const hiddenColumnCount = Object.values(activeHiddenColumns).filter(Boolean).length; + const hiddenRowCount = Object.values(activeHiddenRows).filter(Boolean).length; const boundsCells = useMemo( () => [...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce>( @@ -368,6 +396,40 @@ export function QExcell({ workbook, onChange }: QExcellProps) { }); } + function addSheet() { + const id = `sheet-${workbook.sheets.length + 1}`; + onChange({ + ...workbook, + activeSheet: id, + sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }] + }); + } + + function updateSelectedColumnHidden(hidden: boolean) { + const hiddenColumns = nextHiddenItems(activeHiddenColumns, selectedColumnLabel, hidden); + onChange({ + ...workbook, + sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenColumns } : sheet)) + }); + } + + function updateSelectedRowHidden(hidden: boolean) { + const hiddenRows = nextHiddenItems(activeHiddenRows, selectedRowLabel, hidden); + onChange({ + ...workbook, + sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenRows } : sheet)) + }); + } + + function showAllHiddenSheetItems() { + onChange({ + ...workbook, + sheets: workbook.sheets.map((sheet) => + sheet.id === activeSheet.id ? { ...sheet, hiddenColumns: undefined, hiddenRows: undefined } : sheet + ) + }); + } + function selectChart(chart: SheetChart) { setSelectedChartId(chart.id); setChartTitle(chart.title); @@ -534,6 +596,26 @@ export function QExcell({ workbook, onChange }: QExcellProps) { Ссылка + +
+
+
+ Лист + + + + +
+ +
+ Ячейка + {selectedCell} + + + {hiddenColumnCount + hiddenRowCount > 0 ? ( + + ) : null} +
+ +
+ Диаграмма + + + + + + {selectedChartId ? ( + + ) : null} + E: {totals.toLocaleString("ru-RU")} +
+
+
+ ))} - {rows.map((row) => ( - - - {visibleColumnLabels.map((_, column) => { - const id = cellId(column, row); - const merge = mergedCellInfo(id, activeMergedCells); - if (merge && !merge.isOrigin) { - return null; - } + {rows.map((row) => { + const rowLabel = String(row + 1); + const isHiddenRow = Boolean(activeHiddenRows[rowLabel]); + return ( + + + {visibleColumnLabels.map((columnName, column) => { + const id = cellId(column, row); + const merge = mergedCellInfo(id, activeMergedCells); + if (merge && !merge.isOrigin) { + return null; + } - const raw = activeSheet.cells[id] ?? ""; - const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw; - const hyperlink = activeHyperlinks[id] ?? ""; - const cellFormat = activeCellFormats[id]; - const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat); - return ( -
{visibleColumnLabels.map((column) => ( - {column} + {column} +
{row + 1}
+ {rowLabel} + - {cellFormat?.wrapText ? ( -