mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 22:32:17 +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",
|
||||
"example": "Example",
|
||||
"externalLinks": "External links",
|
||||
"openFolder": "Open folder",
|
||||
"faster": "Faster",
|
||||
"favorite": "Favorite",
|
||||
"filter_one": "Filter",
|
||||
@@ -785,7 +786,7 @@
|
||||
"crossfadeDuration": "Crossfade duration",
|
||||
"crossfadeStyle": "Crossfade style",
|
||||
"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",
|
||||
"customCssEnable_description": "Allow for writing custom CSS",
|
||||
"customCssEnable": "Enable custom CSS",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
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 { promises as fs, watch as fsWatch } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const getFrame = () => {
|
||||
@@ -26,6 +37,67 @@ const storePath = isDevelopment
|
||||
? path.normalize(`${defaultUserDataPath}-dev`)
|
||||
: 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>({
|
||||
beforeEachMigration: (_store, context) => {
|
||||
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;
|
||||
});
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
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) => {
|
||||
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
|
||||
};
|
||||
@@ -88,14 +118,17 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
||||
|
||||
export const utils = {
|
||||
checkForUpdates,
|
||||
customCssUpdatedListener,
|
||||
disableAutoUpdates,
|
||||
download,
|
||||
forceGarbageCollection,
|
||||
getCustomCss,
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
mainMessageListener,
|
||||
openApplicationDirectory,
|
||||
openCustomCssFolder,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
rendererOpenCommandPalette,
|
||||
@@ -105,6 +138,7 @@ export const utils = {
|
||||
rendererTogglePrivateMode,
|
||||
rendererToggleSidebar,
|
||||
rendererUpdateAvailable,
|
||||
saveCustomCss,
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
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 { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
||||
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 { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
import { WebAudio } from '/@/shared/types/types';
|
||||
@@ -31,6 +36,7 @@ const UpdateAvailableDialog = lazy(() =>
|
||||
);
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const App = () => {
|
||||
return <ThemedApp />;
|
||||
@@ -89,6 +95,7 @@ const AppEffects = () => (
|
||||
<>
|
||||
<SyncSettingsEffect />
|
||||
<UpdateCheckEffect />
|
||||
<CustomCssFileEffect />
|
||||
<CssSettingsEffect />
|
||||
<GlobalShortcutsEffect />
|
||||
<LanguageEffect />
|
||||
@@ -142,6 +149,71 @@ const CssSettingsEffect = () => {
|
||||
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 { bindings } = useHotkeySettings();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,18 +15,39 @@ export const StylesSettings = memo(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
const isDesktop = isElectron();
|
||||
|
||||
const { content, enabled } = useCssSettings();
|
||||
const [css, setCss] = useState(content);
|
||||
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
setSettings({
|
||||
css: {
|
||||
content: css,
|
||||
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(() => {
|
||||
@@ -62,6 +84,15 @@ export const StylesSettings = memo(() => {
|
||||
<SettingsOptions
|
||||
control={
|
||||
<>
|
||||
{isDesktop && (
|
||||
<Button
|
||||
onClick={handleOpenFolder}
|
||||
size="compact-md"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.openFolder', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
)}
|
||||
{open && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
|
||||
Reference in New Issue
Block a user