From 502ce2021adbeec2289ab13a5d201fcb09f63915 Mon Sep 17 00:00:00 2001 From: Ouyang Xingyuan Date: Sun, 16 Aug 2026 21:55:59 +0800 Subject: [PATCH] feat(packages): extract reusable Word chrome for React hosts Word chrome lived inside the SneakRead app shell, so React hosts had to copy source and could not consume upstream improvements safely. Add an installable @sneakread/react workspace package with scoped CSS, typed localization and host menu slots. SneakRead now consumes the package for both home and article Word surfaces, and the package contract excludes reader, analytics, Monaco, app-core and bundled React. --- package.json | 6 +- packages/react/LICENSE | 21 ++ packages/react/README.md | 19 ++ packages/react/contract.mjs | 20 ++ packages/react/package.json | 40 +++ packages/react/src/index.ts | 2 + packages/react/src/vite-env.d.ts | 1 + packages/react/src/word.css | 506 +++++++++++++++++++++++++++++++ packages/react/src/word.tsx | 310 +++++++++++++++++++ packages/react/tsconfig.json | 19 ++ packages/react/vite.config.ts | 18 ++ pnpm-lock.yaml | 25 ++ pnpm-workspace.yaml | 3 + src/App.css | 23 -- src/App.tsx | 13 +- src/skins/shared/index.tsx | 164 +--------- src/skins/word/index.tsx | 9 +- 17 files changed, 1003 insertions(+), 196 deletions(-) create mode 100644 packages/react/LICENSE create mode 100644 packages/react/README.md create mode 100644 packages/react/contract.mjs create mode 100644 packages/react/package.json create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/vite-env.d.ts create mode 100644 packages/react/src/word.css create mode 100644 packages/react/src/word.tsx create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/vite.config.ts diff --git a/package.json b/package.json index 7fcc1a5..61f3336 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build && vite build --ssr src/entry-ssr.tsx --outDir dist-ssr && node scripts/prerender.mjs", - "build:client": "tsc -b && vite build", + "predev": "pnpm --filter @sneakread/react build", + "build": "pnpm --filter @sneakread/react build && tsc -b && vite build && vite build --ssr src/entry-ssr.tsx --outDir dist-ssr && node scripts/prerender.mjs", + "build:client": "pnpm --filter @sneakread/react build && tsc -b && vite build", "lint": "oxlint", "preview": "vite preview" }, "dependencies": { + "@sneakread/react": "workspace:*", "@fluentui/react-components": "^9.74.3", "@fluentui/react-icons": "^2.0.316", "@fontsource/arimo": "^5.2.8", diff --git a/packages/react/LICENSE b/packages/react/LICENSE new file mode 100644 index 0000000..6219ff1 --- /dev/null +++ b/packages/react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SneakRead · 刘小排 (Liu Xiaopai) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..0832a4b --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,19 @@ +# @sneakread/react + +Small React host package for the reusable SneakRead Word application chrome. + +```tsx +import { WordFrame } from '@sneakread/react/word' +import '@sneakread/react/word/style.css' + + +
Host-owned document content
+
+``` + +React is a peer dependency. The optional `fileMenu` slot is rendered inside +the real File tab; when omitted, the tab remains discoverable through +`data-appmenu="file"` for host menu delegation. + +`locale` selects the proofing language and localized status text. Hosts that +localize the complete Word chrome should pass typed `labels` overrides. diff --git a/packages/react/contract.mjs b/packages/react/contract.mjs new file mode 100644 index 0000000..0bfc0e3 --- /dev/null +++ b/packages/react/contract.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const root = new URL('.', import.meta.url) +const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8')) +const js = await readFile(new URL('dist/index.js', root), 'utf8') +const css = await readFile(new URL('dist/style.css', root), 'utf8') +const license = await readFile(new URL('LICENSE', root), 'utf8') + +assert.equal(pkg.peerDependencies.react, '>=18') +assert.equal(pkg.exports['./word'].import, './dist/index.js') +assert.equal(pkg.exports['./word/style.css'], './dist/style.css') +assert.match(js, /data-appmenu/) +assert.doesNotMatch(js, /monaco|reader|analytics|react-dom|src\/(App|core)/i) +assert.match(css, /\.sr-word-embed/) +assert.doesNotMatch(css, /(^|[,{])\s*(body|:root|html)\b/) +assert.match(license, /MIT License/) +assert.match(license, /SneakRead/) + +console.log('Word package contract passed') diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..01c6774 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,40 @@ +{ + "name": "@sneakread/react", + "version": "0.1.0", + "private": false, + "description": "Embeddable SneakRead application chrome for React hosts.", + "license": "MIT", + "type": "module", + "files": ["dist", "LICENSE"], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./word": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./word/style.css": "./dist/style.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -p tsconfig.json && vite build", + "check": "pnpm build && node contract.mjs", + "prepare": "pnpm build" + }, + "peerDependencies": { + "react": ">=18" + }, + "dependencies": { + "@fluentui/react-icons": "^2.0.316" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@vitejs/plugin-react": "^6.0.3", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000..ed01b85 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,2 @@ +export { WordFrame } from './word' +export type { WordChromeLabels, WordFrameProps } from './word' diff --git a/packages/react/src/vite-env.d.ts b/packages/react/src/vite-env.d.ts new file mode 100644 index 0000000..5894ae0 --- /dev/null +++ b/packages/react/src/vite-env.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/packages/react/src/word.css b/packages/react/src/word.css new file mode 100644 index 0000000..74149f2 --- /dev/null +++ b/packages/react/src/word.css @@ -0,0 +1,506 @@ +.sr-word-embed { + --sr-word-accent: #2b579a; + --sr-word-body: "Aptos", "Segoe UI", Carlito, Inter, sans-serif; + height: 100%; + min-height: 0; + min-width: 0; + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 44px 32px auto 22px minmax(0, 1fr) 24px; + overflow: hidden; + color: #201f1e; + background: #fff; + font-family: "Segoe UI", Inter, sans-serif; + font-size: 13px; + line-height: 1.25; + box-sizing: border-box; +} + +.sr-word-embed *, +.sr-word-embed *::before, +.sr-word-embed *::after { + box-sizing: border-box; +} + +.sr-word-embed button, +.sr-word-embed input { + font: inherit; +} + +.sr-word-embed a { + color: inherit; +} + +.sr-word-titlebar { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + overflow: hidden; + padding: 0 0 0 10px; + color: #fff; + background: var(--sr-word-accent); +} + +.sr-word-qat { + display: flex; + align-items: center; + flex: 0 1 auto; + gap: 8px; + min-width: 0; + white-space: nowrap; + font-size: 11px; +} + +.sr-word-qat a { + color: rgba(255, 255, 255, 0.94); + text-decoration: none; +} + +.sr-word-qat button { + display: grid; + width: 23px; + height: 23px; + place-items: center; + padding: 0; + color: rgba(255, 255, 255, 0.9); + background: transparent; + border: 0; + cursor: pointer; +} + +.sr-word-qat button:hover { + background: rgba(255, 255, 255, 0.14); +} + +.sr-word-logo { + flex: 0 0 auto; +} + +.sr-word-autosave { + display: inline-flex; + align-items: center; + gap: 5px; + color: rgba(255, 255, 255, 0.9); + font-size: 11px; +} + +.sr-word-autosave-track { + position: relative; + width: 25px; + height: 13px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.35); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5); +} + +.sr-word-autosave-knob { + position: absolute; + top: 2px; + right: 2px; + width: 9px; + height: 9px; + border-radius: 50%; + background: #fff; +} + +.sr-word-doctitle { + display: flex; + flex: 0 1 245px; + flex-direction: column; + align-items: center; + min-width: 0; + margin-inline-start: 4px; + line-height: 1.1; + text-align: center; +} + +.sr-word-doctitle-name, +.sr-word-doctitle-sub { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sr-word-doctitle-name { + font-size: 13px; +} + +.sr-word-doctitle-sub { + color: rgba(255, 255, 255, 0.72); + font-size: 10px; +} + +.sr-word-search { + display: flex; + flex: 1 1 260px; + align-items: center; + gap: 8px; + min-width: 80px; + max-width: 420px; + height: 26px; + margin: 0 auto; + padding: 0 10px; + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.16); + border-radius: 4px; + font-size: 13px; +} + +.sr-word-search svg { + flex: 0 0 auto; + width: 16px; + height: 16px; +} + +.sr-word-search span:not(.sr-word-search-kbd) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sr-word-search-kbd { + margin-inline-start: auto; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; +} + +.sr-word-titlebar-right { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 14px; + margin-inline-start: auto; +} + +.sr-word-account { + display: grid; + width: 26px; + height: 26px; + place-items: center; + color: #fff; + background: rgba(255, 255, 255, 0.28); + border-radius: 50%; + font-size: 12px; + font-weight: 600; +} + +.sr-word-window-buttons { + display: flex; + align-self: stretch; +} + +.sr-word-window-button { + display: grid; + width: 42px; + place-items: center; + color: rgba(255, 255, 255, 0.92); +} + +.sr-word-window-close:hover { + background: #c42b1c; +} + +.sr-word-ribbon-tabs { + display: flex; + align-items: flex-end; + gap: 1px; + min-width: 0; + overflow-x: auto; + padding: 0 6px; + color: rgba(255, 255, 255, 0.92); + background: var(--sr-word-accent); + font-size: 13px; + scrollbar-width: none; +} + +.sr-word-ribbon-tabs::-webkit-scrollbar { + display: none; +} + +.sr-word-ribbon-tabs > span { + flex: 0 0 auto; + padding: 7px 11px 8px; + border-radius: 4px 4px 0 0; + cursor: default; +} + +.sr-word-ribbon-tabs > span:not(.sr-word-is-active):not(.sr-word-file-tab):hover { + background: rgba(255, 255, 255, 0.14); +} + +.sr-word-file-tab { + color: #fff; + background: rgba(0, 0, 0, 0.16); + font-weight: 600; +} + +.sr-word-file-tab:hover { + background: rgba(0, 0, 0, 0.24); +} + +.sr-word-is-active { + color: #201f1e !important; + background: #f3f2f1; + font-weight: 600; +} + +.sr-word-tabs-right { + display: flex; + align-items: center; + gap: 6px; + margin-inline-start: auto; + padding-inline-end: 8px; + padding-bottom: 4px; + color: #fff; + font-size: 12px; +} + +.sr-word-tabs-command, +.sr-word-tabs-share { + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; +} + +.sr-word-tabs-command:hover { + background: rgba(255, 255, 255, 0.16); +} + +.sr-word-tabs-share { + padding-inline: 12px; + color: #201f1e; + background: #fff; + font-weight: 600; +} + +.sr-word-ribbon { + display: flex; + align-items: stretch; + gap: 0; + min-width: 0; + overflow-x: auto; + padding: 6px 4px; + background: #f3f2f1; + border-bottom: 1px solid #d2d0ce; + scrollbar-width: thin; +} + +.sr-word-group { + display: flex; + flex: 0 0 auto; + flex-direction: column; + justify-content: space-between; + padding: 2px 10px; + border-right: 1px solid #e1dfdd; +} + +.sr-word-group-grow { + flex: 0 1 auto; +} + +.sr-word-row { + display: flex; + align-items: center; + gap: 2px; +} + +.sr-word-column { + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; +} + +.sr-word-group-name { + padding-top: 4px; + color: #797775; + font-size: 11px; + text-align: center; +} + +.sr-word-button-small, +.sr-word-button-row, +.sr-word-button-large { + color: #323130; + background: transparent; + border: 0; + border-radius: 4px; + cursor: default; +} + +.sr-word-button-small:hover, +.sr-word-button-row:hover, +.sr-word-button-large:hover { + background: #eaeaea; +} + +.sr-word-button-small { + display: grid; + width: 28px; + height: 28px; + place-items: center; + padding: 0; +} + +.sr-word-button-small svg, +.sr-word-button-row svg { + width: 20px; + height: 20px; +} + +.sr-word-button-highlight svg { + color: #d6a300; +} + +.sr-word-button-color svg { + color: #c0392b; +} + +.sr-word-button-row { + display: flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 6px; + font-size: 12px; +} + +.sr-word-button-large { + display: flex; + min-width: 54px; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 4px 8px; + font-size: 12px; +} + +.sr-word-button-large svg { + width: 24px; + height: 24px; +} + +.sr-word-font-row { + gap: 6px; +} + +.sr-word-fontbox, +.sr-word-sizebox { + display: flex; + align-items: center; + height: 22px; + padding: 0 8px; + background: #fff; + border: 1px solid #d2d0ce; + border-radius: 3px; + font-size: 12px; +} + +.sr-word-fontbox { + min-width: 116px; +} + +.sr-word-sizebox { + width: 40px; +} + +.sr-word-styles { + display: flex; + align-items: stretch; + overflow: hidden; + border: 1px solid #e1dfdd; + border-radius: 4px; +} + +.sr-word-style { + display: flex; + min-width: 66px; + align-items: center; + justify-content: center; + padding: 6px 10px; + color: #201f1e; + background: #fff; + border-right: 1px solid #f0eeed; + font-size: 12px; + white-space: nowrap; +} + +.sr-word-style-active { + box-shadow: inset 0 0 0 2px var(--sr-word-accent); +} + +.sr-word-ruler { + background: repeating-linear-gradient(90deg, #eef1f6, #eef1f6 1px, transparent 1px, transparent 32px), #eaeaea; + border-bottom: 1px solid #d2d0ce; +} + +.sr-word-paper-canvas { + min-height: 0; + overflow: auto; + padding: 30px 24px 60px; + background: #e6e6e6; +} + +.sr-word-paper-canvas > * { + width: min(816px, 100%); + min-height: 1056px; + margin: 0 auto; + padding: 84px 96px; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); + font-family: var(--sr-word-body); +} + +.sr-word-status { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; + padding: 0 14px; + color: #fff; + background: var(--sr-word-accent); + font-size: 12px; +} + +.sr-word-status-right { + margin-inline-start: auto; +} + +@media (max-width: 760px) { + .sr-word-embed { + grid-template-rows: 44px 32px minmax(0, 1fr) 24px; + } + + .sr-word-ribbon, + .sr-word-ruler, + .sr-word-search, + .sr-word-autosave, + .sr-word-doctitle-sub { + display: none; + } + + .sr-word-paper-canvas { + padding: 10px 0; + } + + .sr-word-paper-canvas > * { + min-height: 100%; + padding: 36px 22px !important; + } + + .sr-word-ribbon-tabs { + min-width: 0; + } + + .sr-word-tabs-right, + .sr-word-window-buttons { + display: none; + } + + .sr-word-titlebar-right { + margin-inline-start: auto; + } +} diff --git a/packages/react/src/word.tsx b/packages/react/src/word.tsx new file mode 100644 index 0000000..b3b04b7 --- /dev/null +++ b/packages/react/src/word.tsx @@ -0,0 +1,310 @@ +import type { ReactNode } from 'react' +import { + ArrowRedo20Regular, + ArrowUndo20Regular, + ClipboardPaste24Regular, + Copy20Regular, + Cut20Regular, + Highlight20Regular, + Save20Regular, + Search20Regular, + TextAlignCenter20Regular, + TextAlignJustify20Regular, + TextAlignLeft20Regular, + TextAlignRight20Regular, + TextBold20Regular, + TextBulletList20Regular, + TextColor20Regular, + TextItalic20Regular, + TextNumberListLtr20Regular, + TextStrikethrough20Regular, + TextUnderline20Regular, +} from '@fluentui/react-icons' +import './word.css' + +export type WordFrameProps = { + /** The document title shown in the Word title bar. */ + fileName: string + /** Approximate document word count shown in the status bar. */ + wordCount?: number + /** BCP-47 locale used for the status bar proofing language. */ + locale?: string + /** Initial shown in the account avatar. */ + accountInitial?: string + /** Optional host-owned brand/home entry. Defaults to the Word mark. */ + logo?: ReactNode + /** Labels for hosts that localize the surrounding application. */ + labels?: Partial + /** Host-owned File menu mount. Defaults to the discoverable File tab. */ + fileMenu?: ReactNode + children: ReactNode +} + +export type WordChromeLabels = { + tabs: readonly string[] + savedTo: string + search: string + searchShortcut: string + autoSave: string + save: string + undo: string + redo: string + comments: string + share: string + paste: string + cut: string + copy: string + clipboard: string + font: string + fontName: string + fontSize: string + bold: string + italic: string + underline: string + strikethrough: string + highlight: string + textColor: string + paragraph: string + bulletList: string + numberedList: string + alignLeft: string + alignCenter: string + alignRight: string + alignJustify: string + styles: string + styleNames: readonly string[] + editing: string + find: string + replace: string + select: string + pageStatus: (page: number, total: number) => string + wordStatus: (count: number) => string + proofingLanguage: string +} + +const WORD_TABS = [ + 'File', + 'Home', + 'Insert', + 'Draw', + 'Design', + 'Layout', + 'References', + 'Mailings', + 'Review', + 'View', + 'Help', +] + +const DEFAULT_LABELS: WordChromeLabels = { + tabs: WORD_TABS, + savedTo: 'Saved to OneDrive', + search: 'Search', + searchShortcut: 'Alt+Q', + autoSave: 'AutoSave', + save: 'Save', + undo: 'Undo', + redo: 'Redo', + comments: 'Comments', + share: 'Share ▾', + paste: 'Paste', + cut: 'Cut', + copy: 'Copy', + clipboard: 'Clipboard', + font: 'Font', + fontName: 'Aptos (Body)', + fontSize: '11', + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + strikethrough: 'Strikethrough', + highlight: 'Text highlight color', + textColor: 'Font color', + paragraph: 'Paragraph', + bulletList: 'Bulleted list', + numberedList: 'Numbering', + alignLeft: 'Align left', + alignCenter: 'Center', + alignRight: 'Align right', + alignJustify: 'Justify', + styles: 'Styles', + styleNames: ['Normal', 'No Spacing', 'Heading 1', 'Heading 2', 'Title'], + editing: 'Editing', + find: 'Find', + replace: 'Replace', + select: 'Select', + pageStatus: (page, total) => `Page ${page} of ${total}`, + wordStatus: (count) => `${count} words`, + proofingLanguage: 'English (US)', +} + +const STATUS_LANGUAGES: Record = { + en: 'English (US)', + zh: '中文(中国)', + es: 'Español (España)', + hi: 'हिन्दी (भारत)', + ar: 'العربية', + pt: 'Português (Brasil)', + ru: 'Русский (Россия)', + ja: '日本語', + fr: 'Français (France)', + de: 'Deutsch (Deutschland)', +} + +function localeKey(locale: string | undefined) { + return locale?.toLowerCase().split('-')[0] || 'en' +} + +function initial(value: string | undefined) { + return value?.trim().charAt(0).toUpperCase() || 'M' +} + +function WordLogo() { + return ( + + ) +} + +function WindowButtons() { + return ( + + ) +} + +function TitleBar({ fileName, accountInitial, logo, labels }: Pick & { labels: WordChromeLabels }) { + return ( +
+
+ {logo ?? } + + + {labels.autoSave} + + + + +
+
+ {fileName} + {labels.savedTo} +
+
+ + {labels.search} + {labels.searchShortcut} +
+
+ {initial(accountInitial)} + +
+
+ ) +} + +function RibbonTabs({ fileMenu, labels }: { fileMenu?: ReactNode; labels: WordChromeLabels }) { + return ( + + ) +} + +function Ribbon({ labels }: { labels: WordChromeLabels }) { + return ( +
+
+
+ +
+ + +
+
+
{labels.clipboard}
+
+
+
+
{labels.fontName}{labels.fontSize}
+
+ + + + + + +
+
+
{labels.font}
+
+
+
+
+
+
+
{labels.paragraph}
+
+
+
{labels.styleNames.map((style, index) => {style})}
+
{labels.styles}
+
+
+
+
{labels.editing}
+
+
+ ) +} + +export function WordFrame({ fileName, wordCount, locale, accountInitial, logo, labels: labelOverrides, fileMenu, children }: WordFrameProps) { + const key = localeKey(locale) + const labels = { ...DEFAULT_LABELS, ...labelOverrides } + const pageStatus = labelOverrides?.pageStatus ?? (key === 'zh' ? (page, total) => `页面:${page}/${total}` : DEFAULT_LABELS.pageStatus) + const wordStatus = labelOverrides?.wordStatus ?? (key === 'zh' ? (count) => `字数:${count}` : DEFAULT_LABELS.wordStatus) + const proofingLanguage = labelOverrides?.proofingLanguage ?? STATUS_LANGUAGES[key] ?? DEFAULT_LABELS.proofingLanguage + return ( +
+ + + + + ) +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..efd3cf2 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["ES2020", "DOM"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": false, + "outDir": "dist", + "rootDir": "src", + "noEmit": false, + "noEmitOnError": true + }, + "include": ["src"] +} diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts new file mode 100644 index 0000000..d8ee0b5 --- /dev/null +++ b/packages/react/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + emptyOutDir: false, + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: 'index', + cssFileName: 'style', + }, + rollupOptions: { + external: ['react', 'react/jsx-runtime', '@fluentui/react-icons'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4588118..b97885e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@silevis/reactgrid': specifier: ^4.1.17 version: 4.1.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sneakread/react': + specifier: workspace:* + version: link:packages/react '@vscode/codicons': specifier: ^0.0.45 version: 0.0.45 @@ -82,6 +85,28 @@ importers: specifier: ^8.1.1 version: 8.1.3(@types/node@24.13.2)(sass@1.101.0) + packages/react: + dependencies: + '@fluentui/react-icons': + specifier: ^2.0.316 + version: 2.0.316(react@18.3.1) + react: + specifier: '>=18' + version: 18.3.1 + devDependencies: + '@types/react': + specifier: ^18.3.18 + version: 18.3.18 + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.3(@types/node@24.13.2)(sass@1.101.0)) + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.3(@types/node@24.13.2)(sass@1.101.0) + packages: '@babel/runtime@7.29.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 620a7bb..299e97f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - 'packages/*' + allowBuilds: '@parcel/watcher': true diff --git a/src/App.css b/src/App.css index 6814189..79f7e09 100644 --- a/src/App.css +++ b/src/App.css @@ -1860,10 +1860,6 @@ kbd { font-family: 'Segoe UI', var(--sans); } -.word { - grid-template-rows: 34px 32px auto 22px minmax(0, 1fr) 24px; -} - .excel { grid-template-rows: 34px 32px auto 30px minmax(0, 1fr) auto 24px; } @@ -1935,9 +1931,6 @@ kbd { color: rgba(255, 255, 255, 0.6); } -.word-title { - background: #2b579a; -} .excel-title { background: #107c41; } @@ -2286,18 +2279,6 @@ kbd { flex: 0 1 auto; } -.word-ruler { - background: repeating-linear-gradient( - 90deg, - #eef1f6, - #eef1f6 1px, - transparent 1px, - transparent 32px - ), - #eaeaea; - border-bottom: 1px solid #d2d0ce; -} - .paper-canvas { min-height: 0; overflow: auto; @@ -2330,9 +2311,6 @@ kbd { font-size: 12px; } -.word-status { - background: #2b579a; -} .excel-status { background: #107c41; } @@ -5770,7 +5748,6 @@ kbd { @media (max-width: 760px) { /* Office family: keep the colored title bar + tab strip, drop the deep ribbon */ .ms-ribbon, - .word-ruler, .ms-search, .ms-qat .ms-autosave, .ms-doctitle-sub { diff --git a/src/App.tsx b/src/App.tsx index 27176f1..e788ecf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,11 +52,10 @@ import { cleanReaderMarkdown, summarizeMarkdown, } from './core/content' -import { getAlias, setAlias } from './core/alias' +import { aliasInitial, getAlias, setAlias } from './core/alias' import { initAnalytics, track, trackPageView, bucket } from './core/analytics' -import { - WordFrame, -} from './skins/shared' +import { WordFrame } from '@sneakread/react/word' +import '@sneakread/react/word/style.css' import { ExcelBudgetSkin } from './skins/excel' import { VscMenuBar, ActivityBar } from './skins/vscode' import { getSkin, skins, skinById, SkinBrandLogo } from './skins' @@ -2361,9 +2360,11 @@ function WordWelcome(props: HomeProps) { return ( -
+
diff --git a/src/skins/shared/index.tsx b/src/skins/shared/index.tsx index fb5ecfb..8b733e9 100644 --- a/src/skins/shared/index.tsx +++ b/src/skins/shared/index.tsx @@ -13,24 +13,9 @@ import { lang } from '../../i18n' import { skinLabel } from '../labels' import { useSkinControls } from '../controls' import { - Save20Regular, - ArrowUndo20Regular, ArrowRedo20Regular, - ClipboardPaste24Regular, - Cut20Regular, - Copy20Regular, - TextBold20Regular, - TextItalic20Regular, - TextUnderline20Regular, - TextStrikethrough20Regular, - Highlight20Regular, - TextColor20Regular, - TextBulletList20Regular, - TextNumberListLtr20Regular, - TextAlignLeft20Regular, - TextAlignCenter20Regular, - TextAlignRight20Regular, - TextAlignJustify20Regular, + ArrowUndo20Regular, + Save20Regular, Search20Regular, } from '@fluentui/react-icons' @@ -388,148 +373,3 @@ export function MsRibbonTabs({
) } - -// The full Word window (title bar + ribbon + ruler + status), reused by both the -// article skin and the Word home so they share one chrome. -export function WordFrame({ - fileName, - statusWords, - children, -}: { - fileName: string - statusWords?: number - children: ReactNode -}) { - return ( -
- - } /> -
-
-
- -
- - -
-
-
Clipboard
-
- -
-
-
- Aptos (Body) - 11 -
-
- - - - - - -
-
-
Font
-
- -
-
-
- - -
-
- - - - -
-
-
Paragraph
-
- -
-
- {['Normal', 'No Spacing', 'Heading 1', 'Heading 2', 'Title'].map((s, i) => ( - - {s} - - ))} -
-
Styles
-
- -
-
- - - -
-
Editing
-
-
- - ) -} - -// Word shows the proofing language of the document — an English status bar -// under a Chinese document is a quiet giveaway. Keyed by UI language. -const WORD_STATUS_LANG: Record = { - en: 'English (US)', - zh: '中文(中国)', - es: 'Español (España)', - hi: 'हिन्दी (भारत)', - ar: 'العربية', - pt: 'Português (Brasil)', - ru: 'Русский (Россия)', - ja: '日本語', - fr: 'Français (France)', - de: 'Deutsch (Deutschland)', -} diff --git a/src/skins/word/index.tsx b/src/skins/word/index.tsx index 59fbc66..ff9e51a 100644 --- a/src/skins/word/index.tsx +++ b/src/skins/word/index.tsx @@ -1,14 +1,17 @@ import type { DocumentRecord } from '../../core/types' import type { SkinDefinition } from '../types' import { articleBody } from '../../core/content' -import { WordFrame, MarkdownContent, SkinSwitcher } from '../shared' +import { WordFrame } from '@sneakread/react/word' +import { MarkdownContent, SkinSwitcher } from '../shared' +import { aliasInitial } from '../../core/alias' +import { lang } from '../../i18n' // ===== BLOCK APPENDED BELOW ===== export function WordSkin({ doc }: { doc: DocumentRecord }) { const body = articleBody(doc) return ( - -
+ +

{doc.title}