diff --git a/.eslintignore b/.eslintignore index 76734040f..b05d64e42 100644 --- a/.eslintignore +++ b/.eslintignore @@ -598,6 +598,9 @@ packages/app-desktop/gui/Sidebar/styles/index.js.map packages/app-desktop/gui/StatusScreen/StatusScreen.d.ts packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js.map +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.d.ts +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js.map packages/app-desktop/gui/SyncWizard/Dialog.d.ts packages/app-desktop/gui/SyncWizard/Dialog.js packages/app-desktop/gui/SyncWizard/Dialog.js.map @@ -967,6 +970,9 @@ packages/lib/fs-driver-node.js.map packages/lib/fsDriver.test.d.ts packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js.map +packages/lib/hooks/useAsyncEffect.d.ts +packages/lib/hooks/useAsyncEffect.js +packages/lib/hooks/useAsyncEffect.js.map packages/lib/hooks/useElementSize.d.ts packages/lib/hooks/useElementSize.js packages/lib/hooks/useElementSize.js.map @@ -1525,6 +1531,21 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map +packages/lib/services/style/cssToTheme.d.ts +packages/lib/services/style/cssToTheme.js +packages/lib/services/style/cssToTheme.js.map +packages/lib/services/style/cssToTheme.test.d.ts +packages/lib/services/style/cssToTheme.test.js +packages/lib/services/style/cssToTheme.test.js.map +packages/lib/services/style/loadCssToTheme.d.ts +packages/lib/services/style/loadCssToTheme.js +packages/lib/services/style/loadCssToTheme.js.map +packages/lib/services/style/themeToCss.d.ts +packages/lib/services/style/themeToCss.js +packages/lib/services/style/themeToCss.js.map +packages/lib/services/style/themeToCss.test.d.ts +packages/lib/services/style/themeToCss.test.js +packages/lib/services/style/themeToCss.test.js.map packages/lib/services/synchronizer/ItemUploader.d.ts packages/lib/services/synchronizer/ItemUploader.js packages/lib/services/synchronizer/ItemUploader.js.map @@ -1786,6 +1807,9 @@ packages/renderer/utils.js.map packages/tools/buildServerDocker.d.ts packages/tools/buildServerDocker.js packages/tools/buildServerDocker.js.map +packages/tools/convertThemesToCss.d.ts +packages/tools/convertThemesToCss.js +packages/tools/convertThemesToCss.js.map packages/tools/generate-database-types.d.ts packages/tools/generate-database-types.js packages/tools/generate-database-types.js.map diff --git a/.gitignore b/.gitignore index 9cb006831..793f113f3 100644 --- a/.gitignore +++ b/.gitignore @@ -583,6 +583,9 @@ packages/app-desktop/gui/Sidebar/styles/index.js.map packages/app-desktop/gui/StatusScreen/StatusScreen.d.ts packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js.map +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.d.ts +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js +packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js.map packages/app-desktop/gui/SyncWizard/Dialog.d.ts packages/app-desktop/gui/SyncWizard/Dialog.js packages/app-desktop/gui/SyncWizard/Dialog.js.map @@ -952,6 +955,9 @@ packages/lib/fs-driver-node.js.map packages/lib/fsDriver.test.d.ts packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js.map +packages/lib/hooks/useAsyncEffect.d.ts +packages/lib/hooks/useAsyncEffect.js +packages/lib/hooks/useAsyncEffect.js.map packages/lib/hooks/useElementSize.d.ts packages/lib/hooks/useElementSize.js packages/lib/hooks/useElementSize.js.map @@ -1510,6 +1516,21 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map +packages/lib/services/style/cssToTheme.d.ts +packages/lib/services/style/cssToTheme.js +packages/lib/services/style/cssToTheme.js.map +packages/lib/services/style/cssToTheme.test.d.ts +packages/lib/services/style/cssToTheme.test.js +packages/lib/services/style/cssToTheme.test.js.map +packages/lib/services/style/loadCssToTheme.d.ts +packages/lib/services/style/loadCssToTheme.js +packages/lib/services/style/loadCssToTheme.js.map +packages/lib/services/style/themeToCss.d.ts +packages/lib/services/style/themeToCss.js +packages/lib/services/style/themeToCss.js.map +packages/lib/services/style/themeToCss.test.d.ts +packages/lib/services/style/themeToCss.test.js +packages/lib/services/style/themeToCss.test.js.map packages/lib/services/synchronizer/ItemUploader.d.ts packages/lib/services/synchronizer/ItemUploader.js packages/lib/services/synchronizer/ItemUploader.js.map @@ -1771,6 +1792,9 @@ packages/renderer/utils.js.map packages/tools/buildServerDocker.d.ts packages/tools/buildServerDocker.js packages/tools/buildServerDocker.js.map +packages/tools/convertThemesToCss.d.ts +packages/tools/convertThemesToCss.js +packages/tools/convertThemesToCss.js.map packages/tools/generate-database-types.d.ts packages/tools/generate-database-types.js packages/tools/generate-database-types.js.map diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index cbb26217b..cb235fcb3 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -237,7 +237,7 @@ export default class ElectronAppWrapper { const iid = setInterval(() => { if (this.electronApp().isReady()) { clearInterval(iid); - resolve(); + resolve(null); } }, 10); }); diff --git a/packages/app-desktop/gui/Root.tsx b/packages/app-desktop/gui/Root.tsx index 5d79b2893..175519088 100644 --- a/packages/app-desktop/gui/Root.tsx +++ b/packages/app-desktop/gui/Root.tsx @@ -20,6 +20,7 @@ import DialogTitle from './DialogTitle'; import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow'; import Dialog from './Dialog'; import SyncWizardDialog from './SyncWizard/Dialog'; +import StyleSheetContainer from './StyleSheets/StyleSheetContainer'; const { ImportScreen } = require('./ImportScreen.min.js'); const { ResourceScreen } = require('./ResourceScreen.js'); const { Navigator } = require('./Navigator.min.js'); @@ -208,6 +209,7 @@ class RootComponent extends React.Component { return ( + diff --git a/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx b/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx index d87d39b0b..fb5cc75e1 100644 --- a/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx +++ b/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx @@ -16,6 +16,7 @@ import { ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/service import { State } from '@joplin/lib/reducer'; import { connect } from 'react-redux'; import { reg } from '@joplin/lib/registry'; +import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; const logger = Logger.create('ShareFolderDialog'); @@ -100,20 +101,6 @@ interface RecipientDeleteEvent { shareUserId: string; } -interface AsyncEffectEvent { - cancelled: boolean; -} - -function useAsyncEffect(effect: Function, dependencies: any[]) { - useEffect(() => { - const event = { cancelled: false }; - effect(event); - return () => { - event.cancelled = true; - }; - }, dependencies); -} - enum ShareState { Idle = 0, Synchronizing = 1, diff --git a/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx b/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx new file mode 100644 index 000000000..219ef0190 --- /dev/null +++ b/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx @@ -0,0 +1,41 @@ +// This component is perhaps a bit of a hack but the approach should be +// reliable. It converts the current (JS) theme to CSS, and add it to the HEAD +// tag. The component itself doesn't render anything where it's located (just an +// empty invisible DIV), so it means it could be put anywhere and would have the +// same effect. +// +// It's still reliable because the lifecyle of adding the CSS and removing on +// unmout is handled properly. There should only be one such component on the +// page. + +import { useEffect, useState } from 'react'; +import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; +import themeToCss from '@joplin/lib/services/style/themeToCss'; +import { themeById } from '@joplin/lib/theme'; + +interface Props { + themeId: any; +} + +export default function(props: Props): any { + const [styleSheetContent, setStyleSheetContent] = useState(''); + + useAsyncEffect(async (event: AsyncEffectEvent) => { + const theme = themeById(props.themeId); + const themeCss = themeToCss(theme); + if (event.cancelled) return; + setStyleSheetContent(themeCss); + }, [props.themeId]); + + useEffect(() => { + const element = document.createElement('style'); + element.setAttribute('id', 'main-theme-stylesheet-container'); + document.head.appendChild(element); + element.appendChild(document.createTextNode(styleSheetContent)); + return () => { + document.head.removeChild(element); + }; + }, [styleSheetContent]); + + return
; +} diff --git a/packages/app-desktop/style.css b/packages/app-desktop/style.css index ea132522d..602e958fd 100644 --- a/packages/app-desktop/style.css +++ b/packages/app-desktop/style.css @@ -64,28 +64,6 @@ a { opacity: 1; } -/* -.note-list .list-item-container:hover { - background-color: rgba(0,160,255,0.1) !important; -} -*/ - -/* -.editor-toolbar .button:not(.disabled):hover, -.header .button:not(.disabled):hover { - background-color: rgba(0,160,255,0.1); - border: 1px solid rgba(0,160,255,0.5); - box-sizing: 'border-box'; -} - -.editor-toolbar .button:not(.disabled):active, -.header .button:not(.disabled):active { - background-color: rgba(0,160,255,0.2); - border: 1px solid rgba(0,160,255,0.7); - box-sizing: 'border-box'; -} -*/ - .editor-toolbar .button, .header .button { border: 1px solid rgba(0,160,255,0); @@ -163,11 +141,6 @@ a { to {transform: rotate(360deg);} } -/* .joplin-tinymce .tox-editor-header { - padding-left: 88px; - padding-right: 150px; -} */ - *:focus { outline: none; } \ No newline at end of file diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 87ffe33b1..a5b794558 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -641,7 +641,7 @@ async function initialize(dispatch: Function) { class AppComponent extends React.Component { - constructor() { + public constructor() { super(); this.state = { @@ -684,7 +684,7 @@ class AppComponent extends React.Component { // https://github.com/laurent22/joplin/issues/3807 // https://discourse.joplinapp.org/t/webdav-config-encryption-config-randomly-lost-on-android/11364 // https://discourse.joplinapp.org/t/android-keeps-on-resetting-my-sync-and-theme/11443 - async componentDidMount() { + public async componentDidMount() { if (this.props.appState == 'starting') { this.props.dispatch({ type: 'APP_STATE_SET', @@ -737,13 +737,13 @@ class AppComponent extends React.Component { // setTimeout(() => NavService.go('EncryptionConfig'), 2000); } - componentWillUnmount() { + public componentWillUnmount() { AppState.removeEventListener('change', this.onAppStateChange_); Linking.removeEventListener('url', this.handleOpenURL_); if (this.unsubscribeNetInfoHandler_) this.unsubscribeNetInfoHandler_(); } - componentDidUpdate(prevProps: any) { + public componentDidUpdate(prevProps: any) { if (this.props.showSideMenu !== prevProps.showSideMenu) { Animated.timing(this.state.sideMenuContentOpacity, { toValue: this.props.showSideMenu ? 0.5 : 0, @@ -752,7 +752,7 @@ class AppComponent extends React.Component { } } - async backButtonHandler() { + private async backButtonHandler() { if (this.props.noteSelectionEnabled) { this.props.dispatch({ type: 'NOTE_SELECTION_END' }); return true; @@ -773,7 +773,7 @@ class AppComponent extends React.Component { return false; } - async handleShareData() { + private async handleShareData() { const sharedData = await ShareExtension.data(); if (sharedData) { reg.logger().info('Received shared data'); @@ -785,14 +785,14 @@ class AppComponent extends React.Component { } } - UNSAFE_componentWillReceiveProps(newProps: any) { + public UNSAFE_componentWillReceiveProps(newProps: any) { if (newProps.syncStarted != this.lastSyncStarted_) { if (!newProps.syncStarted) FoldersScreenUtils.refreshFolders(); this.lastSyncStarted_ = newProps.syncStarted; } } - sideMenu_change(isOpen: boolean) { + private sideMenu_change(isOpen: boolean) { // Make sure showSideMenu property of state is updated // when the menu is open/closed. this.props.dispatch({ @@ -800,7 +800,7 @@ class AppComponent extends React.Component { }); } - render() { + public render() { if (this.props.appState != 'ready') return null; const theme = themeStyle(this.props.themeId); diff --git a/packages/lib/fs-driver-base.ts b/packages/lib/fs-driver-base.ts index f9556c065..76fdfe317 100644 --- a/packages/lib/fs-driver-base.ts +++ b/packages/lib/fs-driver-base.ts @@ -21,6 +21,38 @@ export default class FsDriverBase { throw new Error('Not implemented'); } + public async readFile(_path: string, _encoding: string = 'utf8'): Promise { + throw new Error('Not implemented'); + } + + public async copy(_source: string, _dest: string) { + throw new Error('Not implemented'); + } + + public async mkdir(_path: string) { + throw new Error('Not implemented'); + } + + public async unlink(_path: string) { + throw new Error('Not implemented'); + } + + public async move(_source: string, _dest: string) { + throw new Error('Not implemented'); + } + + public async readFileChunk(_handle: any, _length: number, _encoding: string = 'base64'): Promise { + throw new Error('Not implemented'); + } + + public async open(_path: string, _mode: any): Promise { + throw new Error('Not implemented'); + } + + public async close(_handle: any): Promise { + throw new Error('Not implemented'); + } + public async readDirStats(_path: string, _options: ReadDirStatsOptions = null): Promise { throw new Error('Not implemented'); } diff --git a/packages/lib/hooks/useAsyncEffect.ts b/packages/lib/hooks/useAsyncEffect.ts new file mode 100644 index 000000000..599a645e8 --- /dev/null +++ b/packages/lib/hooks/useAsyncEffect.ts @@ -0,0 +1,18 @@ +import shim from '../shim'; +const { useEffect } = shim.react(); + +export interface AsyncEffectEvent { + cancelled: boolean; +} + +export type EffectFunction = (event: AsyncEffectEvent)=> Promise; + +export default function(effect: EffectFunction, dependencies: any[]) { + useEffect(() => { + const event: AsyncEffectEvent = { cancelled: false }; + void effect(event); + return () => { + event.cancelled = true; + }; + }, dependencies); +} diff --git a/packages/lib/package-lock.json b/packages/lib/package-lock.json index 8e7a169da..962d64666 100644 --- a/packages/lib/package-lock.json +++ b/packages/lib/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@joplin/lib", - "version": "2.4.0", + "version": "2.4.1", "license": "ISC", "dependencies": { "async-mutex": "^0.1.3", @@ -1003,6 +1003,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/css": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/css/-/css-0.0.33.tgz", + "integrity": "sha512-qjeDgh86R0LIeEM588q65yatc8Yyo/VvSIYFqq8JOIHDolhGNX0rz7k/OuxqDpnpqlefoHj8X4Ai/6hT9IWtKQ==", + "dev": true + }, "node_modules/@types/fs-extra": { "version": "9.0.11", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.11.tgz", diff --git a/packages/lib/services/style/cssToTheme.test.ts b/packages/lib/services/style/cssToTheme.test.ts new file mode 100644 index 000000000..e474ad885 --- /dev/null +++ b/packages/lib/services/style/cssToTheme.test.ts @@ -0,0 +1,28 @@ +import cssToTheme from './cssToTheme'; + +describe('cssToTheme', function() { + + it('should convert a CSS string to a theme', async () => { + const input = ` + :root { + --joplin-appearence: light; + --joplin-color: #333333; + --joplin-background-color: #778899; + + /* Should skip this comment and empty lines */ + + --joplin-background-color-transparent: rgba(255,255,255,0.9); + }`; + + const expected = { + appearence: 'light', + color: '#333333', + backgroundColor: '#778899', + backgroundColorTransparent: 'rgba(255,255,255,0.9)', + }; + + const actual = cssToTheme(input, 'test.css'); + expect(actual).toEqual(expected); + }); + +}); diff --git a/packages/lib/services/style/cssToTheme.ts b/packages/lib/services/style/cssToTheme.ts new file mode 100644 index 000000000..867d70c3e --- /dev/null +++ b/packages/lib/services/style/cssToTheme.ts @@ -0,0 +1,46 @@ +import { Theme } from '../../themes/type'; + +// Need to include it that way due to a bug in the lib: +// https://github.com/reworkcss/css/pull/146#issuecomment-740412799 +const cssParse = require('css/lib/parse'); + +function formatCssToThemeVariable(cssVariable: string): string { + const elements = cssVariable.substr(2).split('-'); + if (elements[0] !== 'joplin') throw new Error(`CSS variable name must start with "--joplin": ${cssVariable}`); + + elements.splice(0, 1); + + return elements.map((e, i) => { + const c = i === 0 ? e[0] : e[0].toUpperCase(); + return c + e.substr(1); + }).join(''); +} + +// function unquoteValue(v:string):string { +// if (v.startsWith("'") && v.endsWith("'") || v.startsWith('"') && v.endsWith('"')) return v.substr(1, v.length - 2); +// return v; +// } + +export default function cssToTheme(css: string, sourceFilePath: string): Theme { + const o = cssParse(css, { + silent: false, + source: sourceFilePath, + }); + + if (!o?.stylesheet?.rules?.length) throw new Error(`Invalid CSS color file: ${sourceFilePath}`); + + // Need "as any" because outdated TS definition file + + const rootRule = o.stylesheet.rules[0]; + if (!rootRule.selectors.includes(':root')) throw new Error('`:root` rule not found'); + + const declarations: any[] = rootRule.declarations; + + const output: any = {}; + for (const declaration of declarations) { + if (declaration.type !== 'declaration') continue; // Skip comment lines + output[formatCssToThemeVariable(declaration.property)] = declaration.value; + } + + return output; +} diff --git a/packages/lib/services/style/loadCssToTheme.ts b/packages/lib/services/style/loadCssToTheme.ts new file mode 100644 index 000000000..b195c5f3b --- /dev/null +++ b/packages/lib/services/style/loadCssToTheme.ts @@ -0,0 +1,27 @@ +import { Theme } from '../../themes/type'; +import { filename } from '../../path-utils'; +import shim from '../../shim'; +import cssToTheme from './cssToTheme'; + +export default async function(cssBaseDir: string): Promise> { + const themeDirs = (await shim.fsDriver().readDirStats(cssBaseDir)).filter((f: any) => f.isDirectory()); + + const output: Record = {}; + + for (const themeDir of themeDirs) { + const themeName = filename(themeDir.path); + const cssFile = `${cssBaseDir}/${themeDir.path}/colors.css`; + const cssContent = await shim.fsDriver().readFile(cssFile, 'utf8'); + + let themeId = themeName; + const manifestFile = `${cssBaseDir}/${themeDir.path}/manifest.json`; + if (await shim.fsDriver().exists(manifestFile)) { + const manifest = JSON.parse(await shim.fsDriver().readFile(manifestFile, 'utf8')); + if (manifest.id) themeId = manifest.id; + } + + output[themeId] = cssToTheme(cssContent, cssFile); + } + + return output; +} diff --git a/packages/lib/services/style/themeToCss.test.ts b/packages/lib/services/style/themeToCss.test.ts new file mode 100644 index 000000000..0ca162153 --- /dev/null +++ b/packages/lib/services/style/themeToCss.test.ts @@ -0,0 +1,105 @@ +import { Theme, ThemeAppearance } from '../../themes/type'; +import themeToCss from './themeToCss'; + +const input: Theme = { + appearance: ThemeAppearance.Light, + + // Color scheme "1" is the basic one, like used to display the note + // content. It's basically dark gray text on white background + backgroundColor: '#ffffff', + backgroundColorTransparent: 'rgba(255,255,255,0.9)', + oddBackgroundColor: '#eeeeee', + color: '#32373F', // For regular text + colorError: 'red', + colorWarn: 'rgb(228,86,0)', + colorWarnUrl: '#155BDA', + colorFaded: '#7C8B9E', // For less important text + colorBright: '#000000', // For important text + dividerColor: '#dddddd', + selectedColor: '#e5e5e5', + urlColor: '#155BDA', + + // Color scheme "2" is used for the sidebar. It's white text over + // dark blue background. + backgroundColor2: '#313640', + color2: '#ffffff', + selectedColor2: '#131313', + colorError2: '#ff6c6c', + colorWarn2: '#ffcb81', + + // Color scheme "3" is used for the config screens for example/ + // It's dark text over gray background. + backgroundColor3: '#F4F5F6', + backgroundColorHover3: '#CBDAF1', + color3: '#738598', + + // Color scheme "4" is used for secondary-style buttons. It makes a white + // button with blue text. + backgroundColor4: '#ffffff', + color4: '#2D6BDC', + + raisedBackgroundColor: '#e5e5e5', + raisedColor: '#222222', + searchMarkerBackgroundColor: '#F7D26E', + searchMarkerColor: 'black', + + warningBackgroundColor: '#FFD08D', + + tableBackgroundColor: 'rgb(247, 247, 247)', + codeBackgroundColor: 'rgb(243, 243, 243)', + codeBorderColor: 'rgb(220, 220, 220)', + codeColor: 'rgb(0,0,0)', + + blockQuoteOpacity: 0.7, + + codeMirrorTheme: 'default', + codeThemeCss: 'atom-one-light.css', +}; + +const expected = ` +:root { + --joplin-appearance: light; + --joplin-background-color: #ffffff; + --joplin-background-color-transparent: rgba(255,255,255,0.9); + --joplin-odd-background-color: #eeeeee; + --joplin-color: #32373F; + --joplin-color-error: red; + --joplin-color-warn: rgb(228,86,0); + --joplin-color-warn-url: #155BDA; + --joplin-color-faded: #7C8B9E; + --joplin-color-bright: #000000; + --joplin-divider-color: #dddddd; + --joplin-selected-color: #e5e5e5; + --joplin-url-color: #155BDA; + --joplin-background-color2: #313640; + --joplin-color2: #ffffff; + --joplin-selected-color2: #131313; + --joplin-color-error2: #ff6c6c; + --joplin-color-warn2: #ffcb81; + --joplin-background-color3: #F4F5F6; + --joplin-background-color-hover3: #CBDAF1; + --joplin-color3: #738598; + --joplin-background-color4: #ffffff; + --joplin-color4: #2D6BDC; + --joplin-raised-background-color: #e5e5e5; + --joplin-raised-color: #222222; + --joplin-search-marker-background-color: #F7D26E; + --joplin-search-marker-color: black; + --joplin-warning-background-color: #FFD08D; + --joplin-table-background-color: rgb(247, 247, 247); + --joplin-code-background-color: rgb(243, 243, 243); + --joplin-code-border-color: rgb(220, 220, 220); + --joplin-code-color: rgb(0,0,0); + --joplin-block-quote-opacity: 0.7; + --joplin-code-mirror-theme: default; + --joplin-code-theme-css: atom-one-light.css; +}`; + +describe('themeToCss', function() { + + it('should a theme to a CSS string', async () => { + const actual = themeToCss(input); + expect(actual.trim()).toBe(expected.trim()); + }); + +}); diff --git a/packages/lib/services/style/themeToCss.ts b/packages/lib/services/style/themeToCss.ts new file mode 100644 index 000000000..ce3b0f7f4 --- /dev/null +++ b/packages/lib/services/style/themeToCss.ts @@ -0,0 +1,24 @@ +import { Theme } from '../../themes/type'; +const { camelCaseToDash, formatCssSize } = require('../../string-utils'); + +// function quoteCssValue(name: string, value: string): string { +// const needsQuote = ['appearance', 'codeMirrorTheme', 'codeThemeCss'].includes(name); +// if (needsQuote) return `'${value}'`; +// return value; +// } + +export default function(theme: Theme) { + const lines = []; + lines.push(':root {'); + + for (const name in theme) { + const value = (theme as any)[name]; + const newName = `--joplin-${camelCaseToDash(name)}`; + const formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value; + lines.push(`\t${newName}: ${formattedValue};`); + } + + lines.push('}'); + + return lines.join('\n'); +} diff --git a/packages/lib/theme.ts b/packages/lib/theme.ts index 7a814d9dd..946e5b571 100644 --- a/packages/lib/theme.ts +++ b/packages/lib/theme.ts @@ -23,7 +23,7 @@ const themes: any = { [Setting.THEME_OLED_DARK]: theme_oledDark, }; -function themeById(themeId: string) { +export function themeById(themeId: string) { if (!themes[themeId]) throw new Error(`Invalid theme ID: ${themeId}`); const output = Object.assign({}, themes[themeId]); @@ -365,7 +365,7 @@ function addExtraStyles(style: any) { const themeCache_: any = {}; -function themeStyle(themeId: number) { +export function themeStyle(themeId: number) { if (!themeId) throw new Error('Theme must be specified'); const zoomRatio = 1; @@ -405,7 +405,7 @@ const cachedStyles_: any = { // cacheKey must be a globally unique key, and must change whenever // the dependencies of the style change. If the style depends only // on the theme, a static string can be provided as a cache key. -function buildStyle(cacheKey: any, themeId: number, callback: Function) { +export function buildStyle(cacheKey: any, themeId: number, callback: Function) { cacheKey = Array.isArray(cacheKey) ? cacheKey.join('_') : cacheKey; // We clear the cache whenever switching themes @@ -425,5 +425,3 @@ function buildStyle(cacheKey: any, themeId: number, callback: Function) { return cachedStyles_.styles[cacheKey].style; } - -export { themeStyle, buildStyle, themeById }; diff --git a/packages/renderer/package-lock.json b/packages/renderer/package-lock.json index ab2fc7668..02712564c 100644 --- a/packages/renderer/package-lock.json +++ b/packages/renderer/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@joplin/renderer", - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "dependencies": { "font-awesome-filetypes": "^2.1.0", diff --git a/packages/tools/convertThemesToCss.ts b/packages/tools/convertThemesToCss.ts new file mode 100644 index 000000000..9c2479313 --- /dev/null +++ b/packages/tools/convertThemesToCss.ts @@ -0,0 +1,48 @@ +import themeToCss from '@joplin/lib/services/style/themeToCss'; +import * as fs from 'fs-extra'; +import { rootDir } from './tool-utils'; +import { filename } from '@joplin/lib/path-utils'; + +function themeIdFromName(name: string) { + const nameToId: Record = { + light: 1, + dark: 2, + oledDark: 22, + solarizedLight: 3, + solarizedDark: 4, + dracula: 5, + nord: 6, + aritimDark: 7, + }; + + if (!nameToId[name]) throw new Error(`Invalid name: ${name}`); + + return nameToId[name]; +} + +async function main() { + const baseThemeDir = `${rootDir}/packages/lib/themes`; + const themeFiles = (await fs.readdir(baseThemeDir)).filter(f => f.endsWith('.js') && f !== 'type.js'); + + for (const themeFile of themeFiles) { + const themeName = filename(themeFile); + const themeDir = `${baseThemeDir}/${themeName}`; + await fs.mkdirp(themeDir); + + const cssFile = `${themeDir}/colors.css`; + const content = require(`${baseThemeDir}/${themeFile}`).default; + const newContent = themeToCss(content); + await fs.writeFile(cssFile, newContent, 'utf8'); + + const manifestFile = `${themeDir}/manifest.json`; + const manifestContent = { + id: themeIdFromName(themeName), + }; + await fs.writeFile(manifestFile, JSON.stringify(manifestContent, null, '\t'), 'utf8'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});