mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-18 09:24:19 +02:00
feat: reading custom css from external file (#2012)
* feat: reading custom css from external file --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -92,6 +92,7 @@
|
|||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"example": "Example",
|
"example": "Example",
|
||||||
"externalLinks": "External links",
|
"externalLinks": "External links",
|
||||||
|
"openFolder": "Open folder",
|
||||||
"faster": "Faster",
|
"faster": "Faster",
|
||||||
"favorite": "Favorite",
|
"favorite": "Favorite",
|
||||||
"filter_one": "Filter",
|
"filter_one": "Filter",
|
||||||
@@ -785,7 +786,7 @@
|
|||||||
"crossfadeDuration": "Crossfade duration",
|
"crossfadeDuration": "Crossfade duration",
|
||||||
"crossfadeStyle": "Crossfade style",
|
"crossfadeStyle": "Crossfade style",
|
||||||
"crossfadeStyle_description": "Select the crossfade style to use for the audio player",
|
"crossfadeStyle_description": "Select the crossfade style to use for the audio player",
|
||||||
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization",
|
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization. Desktop: feishin reads and writes custom.css in the app config directory and reloads it when the file changes",
|
||||||
"customCss": "Custom CSS",
|
"customCss": "Custom CSS",
|
||||||
"customCssEnable_description": "Allow for writing custom CSS",
|
"customCssEnable_description": "Allow for writing custom CSS",
|
||||||
"customCssEnable": "Enable custom CSS",
|
"customCssEnable": "Enable custom CSS",
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import type { TitleTheme } from '/@/shared/types/types';
|
import type { TitleTheme } from '/@/shared/types/types';
|
||||||
|
import type { FSWatcher } from 'fs';
|
||||||
|
|
||||||
import { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
nativeTheme,
|
||||||
|
OpenDialogOptions,
|
||||||
|
safeStorage,
|
||||||
|
shell,
|
||||||
|
} from 'electron';
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import { promises as fs, watch as fsWatch } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const getFrame = () => {
|
const getFrame = () => {
|
||||||
@@ -26,6 +37,67 @@ const storePath = isDevelopment
|
|||||||
? path.normalize(`${defaultUserDataPath}-dev`)
|
? path.normalize(`${defaultUserDataPath}-dev`)
|
||||||
: path.normalize(defaultUserDataPath);
|
: path.normalize(defaultUserDataPath);
|
||||||
|
|
||||||
|
const CUSTOM_CSS_FILENAME = 'custom.css';
|
||||||
|
const customCssPath = path.join(storePath, CUSTOM_CSS_FILENAME);
|
||||||
|
let customCssWatcher: FSWatcher | null = null;
|
||||||
|
let customCssDebounce: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const readCustomCss = async (): Promise<{ content: string; exists: boolean }> => {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(customCssPath, 'utf8');
|
||||||
|
return { content, exists: true };
|
||||||
|
} catch (error) {
|
||||||
|
const fsError = error as NodeJS.ErrnoException;
|
||||||
|
if (fsError.code === 'ENOENT') {
|
||||||
|
return { content: '', exists: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to read custom css file', error);
|
||||||
|
return { content: '', exists: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyCustomCssUpdate = async () => {
|
||||||
|
const { content, exists } = await readCustomCss();
|
||||||
|
BrowserWindow.getAllWindows().forEach((window) => {
|
||||||
|
window.webContents.send('custom-css-updated', {
|
||||||
|
content,
|
||||||
|
exists,
|
||||||
|
path: customCssPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleCustomCssUpdate = () => {
|
||||||
|
if (customCssDebounce) {
|
||||||
|
clearTimeout(customCssDebounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
customCssDebounce = setTimeout(() => {
|
||||||
|
notifyCustomCssUpdate().catch((error) => {
|
||||||
|
console.error('Failed to broadcast custom css update', error);
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCustomCssWatcher = async () => {
|
||||||
|
if (customCssWatcher) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(storePath, { recursive: true });
|
||||||
|
customCssWatcher = fsWatch(storePath, (eventType, filename) => {
|
||||||
|
if (!filename) return;
|
||||||
|
if (filename.toString() !== CUSTOM_CSS_FILENAME) return;
|
||||||
|
|
||||||
|
if (eventType === 'change' || eventType === 'rename') {
|
||||||
|
scheduleCustomCssUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to watch custom css file', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const store = new Store<any>({
|
export const store = new Store<any>({
|
||||||
beforeEachMigration: (_store, context) => {
|
beforeEachMigration: (_store, context) => {
|
||||||
console.log(`settings migrate from ${context.fromVersion} → ${context.toVersion}`);
|
console.log(`settings migrate from ${context.fromVersion} → ${context.toVersion}`);
|
||||||
@@ -120,3 +192,42 @@ ipcMain.handle('open-file-selector', async (_event, options: OpenDialogOptions)
|
|||||||
|
|
||||||
return result.filePaths[0] || null;
|
return result.filePaths[0] || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('custom-css-get', async () => {
|
||||||
|
const { content, exists } = await readCustomCss();
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
exists,
|
||||||
|
path: customCssPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('custom-css-save', async (_event, data: { content: string }) => {
|
||||||
|
const content = typeof data?.content === 'string' ? data.content : '';
|
||||||
|
await fs.mkdir(storePath, { recursive: true });
|
||||||
|
await fs.writeFile(customCssPath, content, 'utf8');
|
||||||
|
await notifyCustomCssUpdate();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('custom-css-open-folder', async () => {
|
||||||
|
await fs.mkdir(storePath, { recursive: true });
|
||||||
|
await shell.openPath(storePath);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady()
|
||||||
|
.then(() => startCustomCssWatcher())
|
||||||
|
.catch((error) => console.error('Failed to start custom css watcher', error));
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
if (customCssWatcher) {
|
||||||
|
customCssWatcher.close();
|
||||||
|
customCssWatcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customCssDebounce) {
|
||||||
|
clearTimeout(customCssDebounce);
|
||||||
|
customCssDebounce = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,36 @@ const openApplicationDirectory = async () => {
|
|||||||
return ipcRenderer.invoke('open-application-directory');
|
return ipcRenderer.invoke('open-application-directory');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCustomCss = async (): Promise<
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
content: string;
|
||||||
|
exists: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
> => {
|
||||||
|
return ipcRenderer.invoke('custom-css-get');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCustomCss = async (content: string) => {
|
||||||
|
return ipcRenderer.invoke('custom-css-save', { content });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCustomCssFolder = async () => {
|
||||||
|
return ipcRenderer.invoke('custom-css-open-folder');
|
||||||
|
};
|
||||||
|
|
||||||
|
const customCssUpdatedListener = (
|
||||||
|
cb: (data: { content?: string; exists?: boolean; path?: string }) => void,
|
||||||
|
) => {
|
||||||
|
const listener = (_event: unknown, data: { content?: string; exists?: boolean }) => cb(data);
|
||||||
|
ipcRenderer.on('custom-css-updated', listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('custom-css-updated', listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const playerErrorListener = (cb: (data: { code: number }) => void) => {
|
const playerErrorListener = (cb: (data: { code: number }) => void) => {
|
||||||
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
|
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
|
||||||
};
|
};
|
||||||
@@ -88,14 +118,17 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
|||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
|
customCssUpdatedListener,
|
||||||
disableAutoUpdates,
|
disableAutoUpdates,
|
||||||
download,
|
download,
|
||||||
forceGarbageCollection,
|
forceGarbageCollection,
|
||||||
|
getCustomCss,
|
||||||
isLinux,
|
isLinux,
|
||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
mainMessageListener,
|
mainMessageListener,
|
||||||
openApplicationDirectory,
|
openApplicationDirectory,
|
||||||
|
openCustomCssFolder,
|
||||||
openItem,
|
openItem,
|
||||||
playerErrorListener,
|
playerErrorListener,
|
||||||
rendererOpenCommandPalette,
|
rendererOpenCommandPalette,
|
||||||
@@ -105,6 +138,7 @@ export const utils = {
|
|||||||
rendererTogglePrivateMode,
|
rendererTogglePrivateMode,
|
||||||
rendererToggleSidebar,
|
rendererToggleSidebar,
|
||||||
rendererUpdateAvailable,
|
rendererUpdateAvailable,
|
||||||
|
saveCustomCss,
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
startPowerSaveBlocker,
|
startPowerSaveBlocker,
|
||||||
stopPowerSaveBlocker,
|
stopPowerSaveBlocker,
|
||||||
|
|||||||
+73
-1
@@ -15,7 +15,12 @@ import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
|||||||
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
|
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
|
||||||
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
||||||
import { AppRouter } from '/@/renderer/router/app-router';
|
import { AppRouter } from '/@/renderer/router/app-router';
|
||||||
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
|
import {
|
||||||
|
useCssSettings,
|
||||||
|
useHotkeySettings,
|
||||||
|
useLanguage,
|
||||||
|
useSettingsStoreActions,
|
||||||
|
} from '/@/renderer/store';
|
||||||
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
||||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||||
import { WebAudio } from '/@/shared/types/types';
|
import { WebAudio } from '/@/shared/types/types';
|
||||||
@@ -31,6 +36,7 @@ const UpdateAvailableDialog = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
|
const utils = isElectron() ? window.api.utils : null;
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
return <ThemedApp />;
|
return <ThemedApp />;
|
||||||
@@ -89,6 +95,7 @@ const AppEffects = () => (
|
|||||||
<>
|
<>
|
||||||
<SyncSettingsEffect />
|
<SyncSettingsEffect />
|
||||||
<UpdateCheckEffect />
|
<UpdateCheckEffect />
|
||||||
|
<CustomCssFileEffect />
|
||||||
<CssSettingsEffect />
|
<CssSettingsEffect />
|
||||||
<GlobalShortcutsEffect />
|
<GlobalShortcutsEffect />
|
||||||
<LanguageEffect />
|
<LanguageEffect />
|
||||||
@@ -142,6 +149,71 @@ const CssSettingsEffect = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CustomCssFileEffect = () => {
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
const { content } = useCssSettings();
|
||||||
|
const latestContentRef = useRef(content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestContentRef.current = content;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron() || !utils) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const applyContent = (rawContent: string | undefined) => {
|
||||||
|
const sanitized = sanitizeCss(`<style>${rawContent ?? ''}`);
|
||||||
|
if (sanitized !== latestContentRef.current) {
|
||||||
|
setSettings({
|
||||||
|
css: {
|
||||||
|
content: sanitized,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCustomCss = async () => {
|
||||||
|
try {
|
||||||
|
const result = await utils.getCustomCss();
|
||||||
|
|
||||||
|
if (disposed || !result) return;
|
||||||
|
|
||||||
|
if (!result.exists && latestContentRef.current) {
|
||||||
|
await utils.saveCustomCss(latestContentRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyContent(result.content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load custom css', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomCssUpdated = (data: { content?: string; exists?: boolean }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (data?.exists === false) {
|
||||||
|
applyContent('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyContent(data?.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomCssUpdatedListener =
|
||||||
|
utils.customCssUpdatedListener(handleCustomCssUpdated);
|
||||||
|
loadCustomCss();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
removeCustomCssUpdatedListener();
|
||||||
|
};
|
||||||
|
}, [setSettings]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const GlobalShortcutsEffect = () => {
|
const GlobalShortcutsEffect = () => {
|
||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import isElectron from 'is-electron';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -14,18 +15,39 @@ export const StylesSettings = memo(() => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const utils = isElectron() ? window.api.utils : null;
|
||||||
|
const isDesktop = isElectron();
|
||||||
|
|
||||||
const { content, enabled } = useCssSettings();
|
const { content, enabled } = useCssSettings();
|
||||||
const [css, setCss] = useState(content);
|
const [css, setCss] = useState(content);
|
||||||
|
|
||||||
const { setSettings } = useSettingsStoreActions();
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
setSettings({
|
setSettings({
|
||||||
css: {
|
css: {
|
||||||
content: css,
|
content: css,
|
||||||
enabled,
|
enabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (utils) {
|
||||||
|
try {
|
||||||
|
await utils.saveCustomCss(css);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save custom css file', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenFolder = async () => {
|
||||||
|
if (!utils) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await utils.openCustomCssFolder();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open custom css folder', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,6 +84,15 @@ export const StylesSettings = memo(() => {
|
|||||||
<SettingsOptions
|
<SettingsOptions
|
||||||
control={
|
control={
|
||||||
<>
|
<>
|
||||||
|
{isDesktop && (
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenFolder}
|
||||||
|
size="compact-md"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t('common.openFolder', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
|||||||
Reference in New Issue
Block a user