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}