feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se

- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window
- ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled
- Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields
- Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
This commit is contained in:
2026-05-17 04:13:02 -07:00
parent 3447103857
commit 81830b3372
39 changed files with 1147 additions and 86 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Defaulted the note-fields note type picker to `Kiku` when available, then Lapis note types, otherwise leaving it blank for manual selection.
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
+4 -1
View File
@@ -360,6 +360,7 @@
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover "primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
"css": {}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
@@ -406,6 +407,7 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
@@ -523,6 +525,7 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
@@ -530,7 +533,7 @@
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards. "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
+24 -16
View File
@@ -324,25 +324,29 @@ See `config.example.jsonc` for detailed configuration options.
```json ```json
{ {
"subtitleStyle": { "subtitleStyle": {
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35,
"fontColor": "#cad3f5", "fontColor": "#cad3f5",
"fontWeight": "600",
"lineHeight": 1.35,
"letterSpacing": "-0.01em",
"wordSpacing": 0,
"fontKerning": "normal",
"textRendering": "geometricPrecision",
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"fontStyle": "normal",
"backgroundColor": "transparent", "backgroundColor": "transparent",
"backdropFilter": "blur(6px)", "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"font-size": "35px",
"font-weight": "600",
"line-height": "1.35",
"letter-spacing": "-0.01em",
"word-spacing": "0",
"font-kerning": "normal",
"text-rendering": "geometricPrecision",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"font-style": "normal",
"backdrop-filter": "blur(6px)"
},
"secondary": { "secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"fontSize": 24,
"fontColor": "#cad3f5", "fontColor": "#cad3f5",
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", "backgroundColor": "transparent",
"backgroundColor": "transparent" "css": {
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"font-size": "24px",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
}
} }
} }
} }
@@ -353,6 +357,7 @@ See `config.example.jsonc` for detailed configuration options.
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) | | `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) | | `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) | | `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) | | `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) | | `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) | | `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
@@ -374,7 +379,9 @@ See `config.example.jsonc` for detailed configuration options.
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | | `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | | `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional) | | `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
The configuration window keeps subtitle color controls separate, then saves the CSS textbox to `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing top-level style keys such as `fontSize` and `textShadow` remain supported for hand-written configs.
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`. Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
@@ -967,6 +974,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
+4 -1
View File
@@ -360,6 +360,7 @@
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover "primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
"css": {}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
@@ -406,6 +407,7 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
@@ -523,6 +525,7 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
@@ -530,7 +533,7 @@
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards. "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
+2 -2
View File
@@ -50,8 +50,8 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:dist": "bun test dist/settings/settings-anki-controls.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
+2 -1
View File
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().knownWords?.highlightEnabled === true; const config = this.deps.getConfig();
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
} }
private shouldAddMinedWordsImmediately(): boolean { private shouldAddMinedWordsImmediately(): boolean {
+4 -2
View File
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
} }
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void { applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true; const wasKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config) ? this.getKnownWordCacheLifecycleConfig(this.config)
: null; : null;
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
}; };
this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config); this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true; const nextKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) { if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
if (this.started) { if (this.started) {
@@ -69,6 +69,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true, autoUpdateNewCards: true,
}, },
nPlusOne: { nPlusOne: {
enabled: false,
minSentenceWords: 3, minSentenceWords: 3,
}, },
metadata: { metadata: {
@@ -76,7 +77,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
isLapis: { isLapis: {
enabled: false, enabled: false,
sentenceCardModel: 'Japanese sentences', sentenceCardModel: 'Lapis',
}, },
isKiku: { isKiku: {
enabled: false, enabled: false,
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = { export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: { subtitleStyle: {
primaryDefaultMode: 'visible', primaryDefaultMode: 'visible',
css: {},
enableJlpt: false, enableJlpt: false,
preserveLineBreaks: false, preserveLineBreaks: false,
autoPauseVideoOnHover: true, autoPauseVideoOnHover: true,
@@ -43,6 +44,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
}, },
secondary: { secondary: {
css: {},
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif', fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
fontSize: 24, fontSize: 24,
fontColor: '#cad3f5', fontColor: '#cad3f5',
@@ -278,6 +278,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately, defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
description: 'Immediately append newly mined card words into the known-word cache.', description: 'Immediately append newly mined card words into the known-word cache.',
}, },
{
path: 'ankiConnect.nPlusOne.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
description: 'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
},
{ {
path: 'ankiConnect.nPlusOne.minSentenceWords', path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number', kind: 'number',
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
description: description:
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.', 'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
}, },
{
path: 'subtitleStyle.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.css,
description:
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
},
{
path: 'subtitleStyle.secondary.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.secondary.css,
description:
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
},
{ {
path: 'subtitleStyle.enableJlpt', path: 'subtitleStyle.enableJlpt',
kind: 'boolean', kind: 'boolean',
+15
View File
@@ -789,6 +789,21 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately; DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} }
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords = const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined && nPlusOneMinSentenceWords !== undefined &&
+46
View File
@@ -10,6 +10,21 @@ import {
isObject, isObject,
} from './shared'; } from './shared';
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
if (!isObject(value)) return undefined;
const declarations: Record<string, string> = {};
for (const [property, declarationValue] of Object.entries(value)) {
if (typeof declarationValue !== 'string') {
return undefined;
}
if (declarationValue.trim().length > 0) {
declarations[property] = declarationValue.trim();
}
}
return declarations;
}
export function applySubtitleDomainConfig(context: ResolveContext): void { export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
@@ -159,6 +174,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor; const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor; const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor; const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
const fallbackFrequencyDictionary = { const fallbackFrequencyDictionary = {
...resolved.subtitleStyle.frequencyDictionary, ...resolved.subtitleStyle.frequencyDictionary,
}; };
@@ -211,6 +228,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleStyle.css = css;
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
warn(
'subtitleStyle.css',
(src.subtitleStyle as { css?: unknown }).css,
resolved.subtitleStyle.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const rawSecondary = isObject(src.subtitleStyle.secondary)
? (src.subtitleStyle.secondary as { css?: unknown })
: undefined;
const secondaryCss = asCssDeclarations(rawSecondary?.css);
if (secondaryCss !== undefined) {
resolved.subtitleStyle.secondary.css = secondaryCss;
} else if (rawSecondary?.css !== undefined) {
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
warn(
'subtitleStyle.secondary.css',
rawSecondary.css,
resolved.subtitleStyle.secondary.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const preserveLineBreaks = asBoolean( const preserveLineBreaks = asBoolean(
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
); );
+40
View File
@@ -28,6 +28,46 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
); );
}); });
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleStyle: {
css: {
'font-size': '42px',
'text-wrap': 'balance',
},
secondary: {
css: {
'text-transform': 'uppercase',
},
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
'font-size': '42px',
'text-wrap': 'balance',
});
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
'text-transform': 'uppercase',
});
const invalid = createResolveContext({
subtitleStyle: {
css: {
'font-size': 42,
} as never,
secondary: {
css: 'font-size: 28px;' as never,
},
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
});
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => { test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
const { context, warnings } = createResolveContext({ const { context, warnings } = createResolveContext({
subtitleStyle: { subtitleStyle: {
+1 -1
View File
@@ -188,7 +188,7 @@ export function buildConfigSettingsSnapshot(
continue; continue;
} }
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue); values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
} }
return { return {
+47 -2
View File
@@ -28,17 +28,60 @@ test('settings registry groups annotation display fields by config group', () =>
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color'); assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
}); });
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').section, 'Frequency Highlighting');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
});
test('settings registry exposes specialized controls for config-assisted inputs', () => { test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks'); assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type'); assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field'); assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
assert.equal(field('keybindings').control, 'mpv-keybindings'); assert.equal(field('keybindings').control, 'mpv-keybindings');
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut'); assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
assert.equal(field('subtitleSidebar.toggleKey').control, 'key-code');
assert.equal(field('stats.toggleKey').control, 'key-code'); assert.equal(field('stats.toggleKey').control, 'key-code');
assert.equal(field('discordPresence.presenceStyle').control, 'select'); assert.equal(field('discordPresence.presenceStyle').control, 'select');
}); });
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
const secondaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => { test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect'); const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled'); assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
@@ -48,7 +91,7 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
); );
const kikuLapis = fields.filter( const kikuLapis = fields.filter(
(candidate) => candidate.section === 'Kiku Features And Lapis Features', (candidate) => candidate.section === 'Kiku/Lapis Features',
); );
assert.deepEqual( assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
@@ -68,6 +111,8 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId', 'jellyfin.deviceId',
'jellyfin.clientName', 'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) { ]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`); assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
} }
+141 -13
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsRestartBehavior, ConfigSettingsRestartBehavior,
} from '../../types/settings'; } from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions'; import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
} from '../../settings/subtitle-style-css';
type Leaf = { type Leaf = {
path: string; path: string;
@@ -67,6 +71,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId', 'jellyfin.deviceId',
'controller.buttonIndices', 'controller.buttonIndices',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const; ] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const; const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
@@ -76,11 +82,19 @@ const JSON_OBJECT_FIELDS = new Set([
'controller.bindings', 'controller.bindings',
'controller.profiles', 'controller.profiles',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
]); ]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']); const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor', 'nPlusOne']); const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor', 'nPlusOne']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
...getSubtitleCssManagedConfigPaths('primary'),
...getSubtitleCssManagedConfigPaths('secondary'),
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry])); const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
@@ -106,7 +120,7 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Behavior', 'Subtitle Sidebar Behavior',
'Note Fields', 'Note Fields',
'Media Capture', 'Media Capture',
'Kiku Features And Lapis Features', 'Kiku/Lapis Features',
'Anki AI', 'Anki AI',
'AnkiConnect Proxy', 'AnkiConnect Proxy',
'AnkiConnect', 'AnkiConnect',
@@ -123,20 +137,48 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled', 'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled', 'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled', 'ankiConnect.isKiku.enabled',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
'secondarySub.defaultMode',
'secondarySub.secondarySubLanguages',
].map((path, index) => [path, index]), ].map((path, index) => [path, index]),
); );
const SUBSECTION_ORDER = new Map<string, number>( const SUBSECTION_ORDER = new Map<string, number>(
['Known Words', 'N+1', 'JLPT', 'Frequency Dictionary', 'Character Names'].map( [
(subsection, index) => [subsection, index], 'Known Words',
), 'N+1',
'JLPT',
'Frequency Highlighting',
'Character Names',
'Mining & Clipboard',
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
); );
const LABEL_OVERRIDES: Record<string, string> = { const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionary': 'Open AniList Override',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode', 'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'subtitlePosition.yPercent': 'Subtitle Position', 'subtitlePosition.yPercent': 'Subtitle Position',
}; };
@@ -150,6 +192,10 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.', 'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel': 'ankiConnect.isLapis.sentenceCardModel':
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.', 'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
'subtitleStyle.css':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
}; };
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -215,6 +261,28 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) { ) {
return { category: 'behavior', section: 'Playback Pause Behavior' }; return { category: 'behavior', section: 'Playback Pause Behavior' };
} }
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.refreshMinutes'
) {
return { category: 'behavior', section: 'Known Words' };
}
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
return { category: 'behavior', section: 'N+1' };
}
if (
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
path === 'subtitleStyle.frequencyDictionary.mode' ||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
path === 'subtitleStyle.frequencyDictionary.topX'
) {
return { category: 'behavior', section: 'Frequency Highlighting' };
}
if ( if (
path.startsWith('ankiConnect.knownWords.') || path.startsWith('ankiConnect.knownWords.') ||
path.startsWith('ankiConnect.nPlusOne.') || path.startsWith('ankiConnect.nPlusOne.') ||
@@ -243,7 +311,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
'subtitleSidebar.autoOpen', 'subtitleSidebar.autoOpen',
'subtitleSidebar.autoScroll', 'subtitleSidebar.autoScroll',
'subtitleSidebar.layout', 'subtitleSidebar.layout',
'subtitleSidebar.toggleKey',
]); ]);
return sidebarBehaviorPaths.has(path) return sidebarBehaviorPaths.has(path)
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' } ? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
@@ -259,7 +326,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
return { category: 'mining-anki', section: 'Media Capture' }; return { category: 'mining-anki', section: 'Media Capture' };
} }
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) { if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku Features And Lapis Features' }; return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
} }
if (path.startsWith('ankiConnect.ai.')) { if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' }; return { category: 'mining-anki', section: 'Anki AI' };
@@ -279,6 +346,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) { ) {
return { category: 'playback-sources', section: topSection(path) }; return { category: 'playback-sources', section: topSection(path) };
} }
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('shortcuts.')) { if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay Shortcuts' }; return { category: 'input', section: 'Overlay Shortcuts' };
} }
@@ -346,6 +416,7 @@ function topSection(path: string): string {
function controlForPath(path: string, value: unknown): ConfigSettingsControl { function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret'; if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings'; if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks'; if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type'; if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
@@ -376,17 +447,71 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
} }
function subsectionForPath(path: string): string | undefined { function subsectionForPath(path: string): string | undefined {
if (path.startsWith('ankiConnect.knownWords.')) return 'Known Words'; if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path.startsWith('ankiConnect.nPlusOne.')) return 'N+1'; if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words'; if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1'; if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) { if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
return 'JLPT'; return 'JLPT';
} }
if (path.startsWith('subtitleStyle.frequencyDictionary.')) return 'Frequency Dictionary'; if (
path === 'subtitleStyle.frequencyDictionary.enabled' ||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') { if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
return 'Character Names'; return 'Character Names';
} }
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
return 'Default Fold State';
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
leaf === 'mineSentence' ||
leaf === 'mineSentenceMultiple' ||
leaf === 'updateLastCardFromClipboard' ||
leaf === 'triggerFieldGrouping' ||
leaf === 'markAudioCard'
) {
return 'Mining & Clipboard';
}
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
) {
return 'Toggle & Visibility';
}
if (
leaf === 'openCharacterDictionary' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
) {
return 'Open Panels';
}
if (leaf === 'triggerSubsync') return 'Playback';
return undefined;
}
return undefined; return undefined;
} }
@@ -418,9 +543,9 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const sectionName = a.section.localeCompare(b.section); const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName; if (sectionName !== 0) return sectionName;
const subsection = const aSubOrder = a.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
(SUBSECTION_ORDER.get(a.subsection ?? '') ?? Number.MAX_SAFE_INTEGER) - const bSubOrder = b.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
(SUBSECTION_ORDER.get(b.subsection ?? '') ?? Number.MAX_SAFE_INTEGER); const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection; if (subsection !== 0) return subsection;
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? ''); const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
@@ -446,7 +571,9 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') || pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') || pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' || path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai') pathStartsWith(path, 'ankiConnect.ai') ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
) { ) {
return 'hot-reload'; return 'hot-reload';
} }
@@ -474,6 +601,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
leaf.path.startsWith('immersionTracking.retention.') || leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'), leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path), secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
}; };
} }
+2 -2
View File
@@ -2576,7 +2576,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
const config = getResolvedConfig(); const config = getResolvedConfig();
const nPlusOneEnabled = getRuntimeBooleanOption( const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled, config.ankiConnect.nPlusOne.enabled,
); );
const jlptEnabled = getRuntimeBooleanOption( const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
@@ -4187,7 +4187,7 @@ const {
getNPlusOneEnabled: () => getNPlusOneEnabled: () =>
getRuntimeBooleanOption( getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
getResolvedConfig().ankiConnect.knownWords.highlightEnabled, getResolvedConfig().ankiConnect.nPlusOne.enabled,
), ),
getMinSentenceWordsForNPlusOne: () => getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
+49
View File
@@ -426,6 +426,55 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
} }
}); });
test('applySubtitleStyle applies primary and secondary css declaration objects', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
fontSize: 35,
css: {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-outline': '1px',
},
secondary: {
fontSize: 24,
css: {
'font-size': '28px',
'text-transform': 'uppercase',
},
},
} as never);
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
.values;
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
.values;
assert.equal(primaryValues?.get('font-size'), '42px');
assert.equal(primaryValues?.get('text-wrap'), 'balance');
assert.equal(primaryValues?.get('--subtitle-outline'), '1px');
assert.equal(secondaryValues?.get('font-size'), '28px');
assert.equal(secondaryValues?.get('text-transform'), 'uppercase');
} finally {
restoreDocument();
}
});
test('annotated subtitle tokens inherit configured base subtitle typography', () => { test('annotated subtitle tokens inherit configured base subtitle typography', () => {
const restoreDocument = installFakeDocument(); const restoreDocument = installFakeDocument();
try { try {
+45 -5
View File
@@ -158,6 +158,32 @@ function applyInlineStyleDeclarations(
} }
} }
function normalizeCssDeclarationObject(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const declarations: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value)) {
if (typeof rawValue !== 'string') continue;
const cssValue = rawValue.trim();
if (cssValue.length > 0) declarations[key] = cssValue;
}
return declarations;
}
function applySubtitleCssDeclarations(
root: HTMLElement,
container: HTMLElement,
declarations: Record<string, string>,
): void {
applyInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
applyInlineStyleDeclarations(
container,
pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS),
);
}
function pickInlineStyleDeclarations( function pickInlineStyleDeclarations(
declarations: Record<string, unknown>, declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>, includedKeys: ReadonlySet<string>,
@@ -172,7 +198,9 @@ function pickInlineStyleDeclarations(
const CONTAINER_STYLE_KEYS = new Set<string>([ const CONTAINER_STYLE_KEYS = new Set<string>([
'background', 'background',
'background-color',
'backgroundColor', 'backgroundColor',
'backdrop-filter',
'backdropFilter', 'backdropFilter',
'WebkitBackdropFilter', 'WebkitBackdropFilter',
'webkitBackdropFilter', 'webkitBackdropFilter',
@@ -180,7 +208,7 @@ const CONTAINER_STYLE_KEYS = new Set<string>([
]); ]);
function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string { function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string {
for (const key of ['backgroundColor', 'background']) { for (const key of ['backgroundColor', 'background-color', 'background']) {
const value = declarations[key]; const value = declarations[key];
if (typeof value === 'string' && value.trim().length > 0) { if (typeof value === 'string' && value.trim().length > 0) {
return value.trim(); return value.trim();
@@ -193,6 +221,7 @@ function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>):
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string { function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
for (const key of [ for (const key of [
'backdropFilter', 'backdropFilter',
'backdrop-filter',
'WebkitBackdropFilter', 'WebkitBackdropFilter',
'webkitBackdropFilter', 'webkitBackdropFilter',
'-webkit-backdrop-filter', '-webkit-backdrop-filter',
@@ -762,20 +791,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
'--subtitle-frequency-band-5-color', '--subtitle-frequency-band-5-color',
frequencyBandedColors[4], frequencyBandedColors[4],
); );
applySubtitleCssDeclarations(
ctx.dom.subtitleRoot,
ctx.dom.subtitleContainer,
normalizeCssDeclarationObject(style.css),
);
const secondaryStyle = style.secondary; const secondaryStyle = style.secondary;
if (!secondaryStyle) return; if (!secondaryStyle) return;
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>; const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
const secondaryCssDeclarations = normalizeCssDeclarationObject(secondaryStyle.css);
applyInlineStyleDeclarations( applyInlineStyleDeclarations(
ctx.dom.secondarySubRoot, ctx.dom.secondarySubRoot,
secondaryStyleDeclarations, secondaryStyleDeclarations,
CONTAINER_STYLE_KEYS, CONTAINER_STYLE_KEYS,
); );
const secondaryContainerStyleDeclarations = pickInlineStyleDeclarations( const secondaryContainerStyleDeclarations = {
secondaryStyleDeclarations, ...pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
CONTAINER_STYLE_KEYS, ...pickInlineStyleDeclarations(secondaryCssDeclarations, CONTAINER_STYLE_KEYS),
); };
ctx.dom.secondarySubContainer.style.setProperty( ctx.dom.secondarySubContainer.style.setProperty(
'--secondary-sub-background-color', '--secondary-sub-background-color',
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations), resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
@@ -800,6 +835,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (secondaryStyle.fontStyle) { if (secondaryStyle.fontStyle) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle; ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
} }
applySubtitleCssDeclarations(
ctx.dom.secondarySubRoot,
ctx.dom.secondarySubContainer,
secondaryCssDeclarations,
);
} }
return { return {
-1
View File
@@ -33,7 +33,6 @@
placeholder="Search" placeholder="Search"
aria-label="Search settings" aria-label="Search settings"
/> />
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
<button id="saveButton" class="primary-button" type="button" disabled>Save</button> <button id="saveButton" class="primary-button" type="button" disabled>Save</button>
</div> </div>
</header> </header>
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as ankiControls from './settings-anki-controls';
test('note field model preference chooses Kiku before configured Lapis default', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
'Kiku',
);
});
test('note field model preference falls back to Lapis when Kiku is unavailable', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
'Lapis Morph',
);
});
test('note field model preference does not treat partial Kiku matches as Kiku', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'),
'Lapis Morph',
);
});
test('note field model preference stays blank when no Kiku or Lapis note type exists', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
'',
);
});
+30 -3
View File
@@ -17,6 +17,7 @@ const state: {
modelFieldNamesErrors: Map<string, string>; modelFieldNamesErrors: Map<string, string>;
noteFieldModelName: string; noteFieldModelName: string;
ankiConnectUrl: string; ankiConnectUrl: string;
noteFieldModelNameManuallySelected: boolean;
} = { } = {
deckNames: null, deckNames: null,
deckNamesLoading: false, deckNamesLoading: false,
@@ -32,6 +33,7 @@ const state: {
modelFieldNamesErrors: new Map(), modelFieldNamesErrors: new Map(),
noteFieldModelName: '', noteFieldModelName: '',
ankiConnectUrl: '', ankiConnectUrl: '',
noteFieldModelNameManuallySelected: false,
}; };
let requestRender = (): void => undefined; let requestRender = (): void => undefined;
@@ -42,11 +44,32 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void { export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel']; const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') { if (
!state.noteFieldModelName &&
!state.noteFieldModelNameManuallySelected &&
typeof configuredNoteType === 'string'
) {
state.noteFieldModelName = configuredNoteType; state.noteFieldModelName = configuredNoteType;
} }
} }
export function selectPreferredNoteFieldModelName(
modelNames: readonly string[],
_currentModelName = '',
): string {
const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku');
if (exactKiku) {
return exactKiku;
}
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
if (lapis) {
return lapis;
}
return '';
}
function normalizeStringArray(value: unknown): string[] { function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value) return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
@@ -168,8 +191,11 @@ async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
if (result.ok) { if (result.ok) {
state.modelNames = uniqueSorted(result.values); state.modelNames = uniqueSorted(result.values);
state.modelNamesError = null; state.modelNamesError = null;
if (!state.noteFieldModelName && state.modelNames[0]) { if (!state.noteFieldModelNameManuallySelected) {
state.noteFieldModelName = state.modelNames[0]; state.noteFieldModelName = selectPreferredNoteFieldModelName(
state.modelNames,
state.noteFieldModelName,
);
} }
} else { } else {
state.modelNames = []; state.modelNames = [];
@@ -318,6 +344,7 @@ export function renderNoteFieldModelPicker(context: SettingsControlContext): HTM
select.value = state.noteFieldModelName; select.value = state.noteFieldModelName;
select.addEventListener('change', () => { select.addEventListener('change', () => {
state.noteFieldModelName = select.value; state.noteFieldModelName = select.value;
state.noteFieldModelNameManuallySelected = true;
requestRender(); requestRender();
}); });
control.append(select); control.append(select);
+1
View File
@@ -2,6 +2,7 @@ import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/
export interface SettingsControlContext { export interface SettingsControlContext {
setFieldError(path: string, message: string | null): void; setFieldError(path: string, message: string | null): void;
resetDraftPath(path: string, defaultValue?: ConfigSettingsSnapshotValue): void;
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void; updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue; valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined; valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
+61 -2
View File
@@ -10,12 +10,23 @@ import {
} from './settings-anki-controls'; } from './settings-anki-controls';
import type { SettingsControlContext } from './settings-control-context'; import type { SettingsControlContext } from './settings-control-context';
import { createElement, isSecretSnapshotValue } from './settings-control-dom'; import { createElement, isSecretSnapshotValue } from './settings-control-dom';
import { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls'; import {
configureKeybindingControls,
renderKeyboardInput,
renderMpvKeybindingsInput,
} from './settings-keybinding-controls';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
parseSubtitleCssDeclarations,
serializeSubtitleCssDeclarations,
} from './subtitle-style-css';
export { renderNoteFieldModelPicker }; export { renderNoteFieldModelPicker };
export function configureSettingsControls(options: { requestRender: () => void }): void { export function configureSettingsControls(options: { requestRender: () => void }): void {
configureAnkiControls(options); configureAnkiControls(options);
configureKeybindingControls(options);
} }
export function initializeSettingsControls( export function initializeSettingsControls(
@@ -90,6 +101,44 @@ function renderStringListInput(
return textarea; return textarea;
} }
function renderCssDeclarationsInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const scope = getSubtitleCssScopeForPath(field.configPath);
const textarea = createElement(
'textarea',
'config-textarea css-declarations',
) as HTMLTextAreaElement;
textarea.spellcheck = false;
if (!scope) return textarea;
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
const values: Record<string, ConfigSettingsSnapshotValue | undefined> = {
[field.configPath]: context.valueForPath(field.configPath),
};
for (const path of managedPaths) {
values[path] = context.valueForPath(path);
}
textarea.value = serializeSubtitleCssDeclarations(scope, values);
textarea.addEventListener('input', () => {
const parsed = parseSubtitleCssDeclarations(textarea.value);
if (!parsed.ok) {
textarea.classList.add('invalid');
context.setFieldError(field.configPath, parsed.error);
return;
}
textarea.classList.remove('invalid');
context.setFieldError(field.configPath, null);
context.updateDraft(field.configPath, parsed.declarations);
for (const path of managedPaths) {
context.resetDraftPath(path, undefined);
}
});
return textarea;
}
export function renderControl( export function renderControl(
field: ConfigSettingsField, field: ConfigSettingsField,
context: SettingsControlContext, context: SettingsControlContext,
@@ -134,7 +183,13 @@ export function renderControl(
if (field.control === 'number') { if (field.control === 'number') {
const input = createElement('input', 'config-input') as HTMLInputElement; const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = 'number'; input.type = 'number';
input.value = typeof value === 'number' ? String(value) : ''; const numericValue =
typeof value === 'number'
? value
: typeof field.defaultValue === 'number'
? field.defaultValue
: NaN;
input.value = Number.isFinite(numericValue) ? String(numericValue) : '';
input.addEventListener('input', () => { input.addEventListener('input', () => {
const next = parseOptionalNumberInputValue(input.value); const next = parseOptionalNumberInputValue(input.value);
if (next.ok) { if (next.ok) {
@@ -174,6 +229,10 @@ export function renderControl(
return renderJsonInput(context, field, value); return renderJsonInput(context, field, value);
} }
if (field.control === 'css-declarations') {
return renderCssDeclarationsInput(context, field);
}
if (field.control === 'textarea') { if (field.control === 'textarea') {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement; const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false; textarea.spellcheck = false;
+26 -2
View File
@@ -12,6 +12,11 @@ import type { SettingsControlContext } from './settings-control-context';
import { createElement } from './settings-control-dom'; import { createElement } from './settings-control-dom';
let activeKeyLearningStop: (() => void) | null = null; let activeKeyLearningStop: (() => void) | null = null;
let requestRender = (): void => undefined;
export function configureKeybindingControls(options: { requestRender: () => void }): void {
requestRender = options.requestRender;
}
function startKeyLearning( function startKeyLearning(
button: HTMLButtonElement, button: HTMLButtonElement,
@@ -107,7 +112,8 @@ export function renderMpvKeybindingsInput(
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field)); const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
const container = createElement('div', 'keybinding-editor'); const container = createElement('div', 'keybinding-editor');
for (const row of rows) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const item = createElement('div', 'keybinding-row'); const item = createElement('div', 'keybinding-row');
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => { const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
row.key = next; row.key = next;
@@ -130,9 +136,27 @@ export function renderMpvKeybindingsInput(
row.commandText = command.value; row.commandText = command.value;
applyMpvRows(context, field, rows); applyMpvRows(context, field, rows);
}); });
item.append(keyButton, command); const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
removeButton.type = 'button';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
rows.splice(i, 1);
applyMpvRows(context, field, rows);
requestRender();
});
item.append(keyButton, command, removeButton);
container.append(item); container.append(item);
} }
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
addButton.type = 'button';
addButton.textContent = 'Add Binding';
addButton.addEventListener('click', () => {
rows.push({ defaultKey: '', key: '', command: null, commandText: '', isDefault: false });
applyMpvRows(context, field, rows);
requestRender();
});
container.append(addButton);
return container; return container;
} }
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('settings toolbar does not expose an open file button', () => {
const html = fs.readFileSync(path.join(process.cwd(), 'src/settings/index.html'), 'utf8');
assert.equal(html.includes('id="openFileButton"'), false);
assert.equal(html.includes('Open File'), false);
});
+45
View File
@@ -4,6 +4,7 @@ import {
createSettingsDraft, createSettingsDraft,
filterSettingsFields, filterSettingsFields,
setDraftValue, setDraftValue,
resetDraftPath,
getDirtyOperations, getDirtyOperations,
} from './settings-model'; } from './settings-model';
import type { ConfigSettingsField } from '../types/settings'; import type { ConfigSettingsField } from '../types/settings';
@@ -31,6 +32,18 @@ const fields: ConfigSettingsField[] = [
defaultValue: true, defaultValue: true,
restartBehavior: 'restart', restartBehavior: 'restart',
}, },
{
id: 'subtitleStyle.fontSize',
label: 'Font Size',
description: 'Hidden behind CSS editor.',
configPath: 'subtitleStyle.fontSize',
category: 'appearance',
section: 'Primary Subtitle Appearance',
control: 'number',
defaultValue: 35,
restartBehavior: 'hot-reload',
settingsHidden: true,
},
]; ];
test('filterSettingsFields searches label, section, and config path', () => { test('filterSettingsFields searches label, section, and config path', () => {
@@ -41,6 +54,16 @@ test('filterSettingsFields searches label, section, and config path', () => {
['subtitleStyle.autoPauseVideoOnHover'], ['subtitleStyle.autoPauseVideoOnHover'],
); );
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []); assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
assert.deepEqual(
filterSettingsFields(fields, { query: 'anki' }).map((field) => field.configPath),
['ankiConnect.enabled'],
);
assert.deepEqual(
filterSettingsFields(fields, { query: 'pause hover' }).map((field) => field.configPath),
['subtitleStyle.autoPauseVideoOnHover'],
);
assert.deepEqual(filterSettingsFields(fields, { query: 'pause anki' }), []);
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
}); });
test('settings draft tracks dirty set and emits save operations', () => { test('settings draft tracks dirty set and emits save operations', () => {
@@ -60,3 +83,25 @@ test('settings draft tracks dirty set and emits save operations', () => {
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true); setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
assert.deepEqual(getDirtyOperations(draft), []); assert.deepEqual(getDirtyOperations(draft), []);
}); });
test('settings draft emits reset operations for css-editor-owned legacy style paths', () => {
const draft = createSettingsDraft({
'subtitleStyle.css': {},
'subtitleStyle.fontSize': 35,
});
setDraftValue(draft, 'subtitleStyle.css', { 'font-size': '42px' });
resetDraftPath(draft, 'subtitleStyle.fontSize', undefined);
assert.deepEqual(getDirtyOperations(draft), [
{
op: 'set',
path: 'subtitleStyle.css',
value: { 'font-size': '42px' },
},
{
op: 'reset',
path: 'subtitleStyle.fontSize',
},
]);
});
+18 -7
View File
@@ -6,7 +6,7 @@ import type {
} from '../types/settings'; } from '../types/settings';
export interface SettingsFilter { export interface SettingsFilter {
category: ConfigSettingsCategory; category?: ConfigSettingsCategory;
query?: string; query?: string;
} }
@@ -20,6 +20,15 @@ function normalizeQuery(query: string | undefined): string {
return (query ?? '').trim().toLowerCase(); return (query ?? '').trim().toLowerCase();
} }
function searchableText(parts: Array<string | undefined>): string {
return parts
.filter(Boolean)
.join(' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.toLowerCase();
}
function valuesEqual(a: unknown, b: unknown): boolean { function valuesEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
@@ -29,24 +38,26 @@ export function filterSettingsFields(
filter: SettingsFilter, filter: SettingsFilter,
): ConfigSettingsField[] { ): ConfigSettingsField[] {
const query = normalizeQuery(filter.query); const query = normalizeQuery(filter.query);
const terms = query.length > 0 ? query.split(/\s+/) : [];
return fields.filter((field) => { return fields.filter((field) => {
if (field.category !== filter.category || field.legacyHidden) { if (field.legacyHidden || field.settingsHidden) {
return false;
}
if (filter.category && field.category !== filter.category) {
return false; return false;
} }
if (!query) { if (!query) {
return true; return true;
} }
const haystack = [ const haystack = searchableText([
field.label, field.label,
field.description, field.description,
field.configPath, field.configPath,
field.section, field.section,
field.subsection ?? '', field.subsection ?? '',
field.enumValues?.join(' ') ?? '', field.enumValues?.join(' ') ?? '',
] ]);
.join(' ') return terms.every((term) => haystack.includes(term));
.toLowerCase();
return haystack.includes(query);
}); });
} }
+57 -21
View File
@@ -20,6 +20,7 @@ import {
setDraftValue, setDraftValue,
type SettingsDraft, type SettingsDraft,
} from './settings-model'; } from './settings-model';
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
declare global { declare global {
interface Window { interface Window {
@@ -76,7 +77,6 @@ const dom = {
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'), categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
categoryMeta: getElement<HTMLElement>('categoryMeta'), categoryMeta: getElement<HTMLElement>('categoryMeta'),
searchInput: getElement<HTMLInputElement>('searchInput'), searchInput: getElement<HTMLInputElement>('searchInput'),
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
saveButton: getElement<HTMLButtonElement>('saveButton'), saveButton: getElement<HTMLButtonElement>('saveButton'),
statusBanner: getElement<HTMLElement>('statusBanner'), statusBanner: getElement<HTMLElement>('statusBanner'),
warningsPanel: getElement<HTMLElement>('warningsPanel'), warningsPanel: getElement<HTMLElement>('warningsPanel'),
@@ -163,6 +163,13 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
syncSaveButton(); syncSaveButton();
} }
function resetDraftPathContext(path: string, defaultValue?: ConfigSettingsSnapshotValue): void {
if (!state.draft) return;
resetDraftPath(state.draft, path, defaultValue);
state.inputErrors.delete(path);
syncSaveButton();
}
function renderWarnings(snapshot: ConfigSettingsSnapshot): void { function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
dom.warningsPanel.replaceChildren(); dom.warningsPanel.replaceChildren();
if (snapshot.warnings.length === 0) { if (snapshot.warnings.length === 0) {
@@ -192,7 +199,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
dom.categoryNav.replaceChildren(); dom.categoryNav.replaceChildren();
for (const category of CATEGORY_ORDER) { for (const category of CATEGORY_ORDER) {
const count = snapshot.fields.filter( const count = snapshot.fields.filter(
(field) => field.category === category && !field.legacyHidden, (field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
).length; ).length;
if (count === 0) continue; if (count === 0) continue;
const button = createElement('button', 'category-button') as HTMLButtonElement; const button = createElement('button', 'category-button') as HTMLButtonElement;
@@ -206,6 +213,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
button.addEventListener('click', () => { button.addEventListener('click', () => {
state.category = category; state.category = category;
render(); render();
dom.settingsContent.scrollTop = 0;
}); });
dom.categoryNav.append(button); dom.categoryNav.append(button);
} }
@@ -222,7 +230,13 @@ function renderField(field: ConfigSettingsField): HTMLElement {
const controlWrap = createElement('div', 'field-control'); const controlWrap = createElement('div', 'field-control');
controlWrap.append( controlWrap.append(
renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath }), renderControl(field, {
setFieldError,
resetDraftPath: resetDraftPathContext,
updateDraft,
valueForField,
valueForPath,
}),
); );
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement; const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
resetButton.type = 'button'; resetButton.type = 'button';
@@ -230,6 +244,12 @@ function renderField(field: ConfigSettingsField): HTMLElement {
resetButton.addEventListener('click', () => { resetButton.addEventListener('click', () => {
if (!state.draft) return; if (!state.draft) return;
resetDraftPath(state.draft, field.configPath, field.defaultValue); resetDraftPath(state.draft, field.configPath, field.defaultValue);
const cssScope = getSubtitleCssScopeForPath(field.configPath);
if (cssScope) {
for (const path of getSubtitleCssManagedConfigPaths(cssScope)) {
resetDraftPath(state.draft, path, undefined);
}
}
state.inputErrors.delete(field.configPath); state.inputErrors.delete(field.configPath);
render(); render();
}); });
@@ -240,13 +260,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void { function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
dom.settingsContent.replaceChildren(); dom.settingsContent.replaceChildren();
const query = state.query.trim();
const fields = filterSettingsFields(snapshot.fields, { const fields = filterSettingsFields(snapshot.fields, {
category: state.category, category: query ? undefined : state.category,
query: state.query, query,
}); });
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category]; if (query) {
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`; const categoryCount = new Set(fields.map((field) => field.category)).size;
dom.categoryTitle.textContent = 'Search results';
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}${
categoryCount > 0
? ` across ${categoryCount} categor${categoryCount === 1 ? 'y' : 'ies'}`
: ''
}`;
} else {
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
}
if (fields.length === 0) { if (fields.length === 0) {
const empty = createElement('div', 'empty-state'); const empty = createElement('div', 'empty-state');
@@ -255,25 +286,35 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
return; return;
} }
const sections = new Map<string, ConfigSettingsField[]>(); const sections = new Map<
string,
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
>();
for (const field of fields) { for (const field of fields) {
const sectionFields = sections.get(field.section) ?? []; const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
sectionFields.push(field); const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
sections.set(field.section, sectionFields); section.fields.push(field);
sections.set(title, section);
} }
for (const [section, sectionFields] of sections) { for (const section of sections.values()) {
const sectionEl = createElement('section', 'settings-section'); const sectionEl = createElement('section', 'settings-section');
const title = createElement('h2'); const title = createElement('h2');
title.textContent = section; title.textContent = section.title;
sectionEl.append(title); sectionEl.append(title);
if (section === 'Note Fields') { if (section.rawSection === 'Note Fields') {
sectionEl.append( sectionEl.append(
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }), renderNoteFieldModelPicker({
setFieldError,
resetDraftPath: resetDraftPathContext,
updateDraft,
valueForField,
valueForPath,
}),
); );
} }
let currentSubsection = ''; let currentSubsection = '';
for (const field of sectionFields) { for (const field of section.fields) {
if (field.subsection && field.subsection !== currentSubsection) { if (field.subsection && field.subsection !== currentSubsection) {
currentSubsection = field.subsection; currentSubsection = field.subsection;
const subsectionTitle = createElement('h3', 'settings-subsection-title'); const subsectionTitle = createElement('h3', 'settings-subsection-title');
@@ -353,11 +394,6 @@ dom.searchInput.addEventListener('input', () => {
dom.saveButton.addEventListener('click', () => { dom.saveButton.addEventListener('click', () => {
void save(); void save();
}); });
dom.openFileButton.addEventListener('click', () => {
void window.configSettingsAPI.openSettingsFile().catch((error) => {
setStatus(error instanceof Error ? error.message : 'Failed to open settings file', 'error');
});
});
void loadSnapshot().catch((error) => { void loadSnapshot().catch((error) => {
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error'); setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
+8 -2
View File
@@ -262,7 +262,7 @@ h1 {
} }
.search-input { .search-input {
width: 210px; width: min(360px, 34vw);
height: 36px; height: 36px;
padding: 0 12px; padding: 0 12px;
} }
@@ -296,6 +296,12 @@ h1 {
min-height: 86px; min-height: 86px;
} }
.config-textarea.css-declarations {
width: min(560px, 100%);
min-height: 188px;
tab-size: 2;
}
.search-input:hover, .search-input:hover,
.config-input:hover, .config-input:hover,
.config-textarea:hover { .config-textarea:hover {
@@ -675,7 +681,7 @@ code {
.keybinding-row { .keybinding-row {
display: grid; display: grid;
grid-template-columns: minmax(146px, 0.78fr) minmax(220px, 1.22fr); grid-template-columns: minmax(146px, 0.78fr) minmax(180px, 1.22fr) auto;
gap: 8px; gap: 8px;
align-items: start; align-items: start;
} }
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getSubtitleCssManagedConfigPaths,
parseSubtitleCssDeclarations,
serializeSubtitleCssDeclarations,
} from './subtitle-style-css';
test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => {
const css = serializeSubtitleCssDeclarations('primary', {
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
'subtitleStyle.fontSize': 35,
'subtitleStyle.fontColor': '#cad3f5',
'subtitleStyle.backgroundColor': 'transparent',
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
'subtitleStyle.css': {
filter: 'drop-shadow(0 0 8px #000)',
'--subtitle-outline': '1px',
},
});
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
assert.match(css, /font-size: 35px;/);
assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/);
assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/);
assert.match(css, /--subtitle-outline: 1px;/);
assert.doesNotMatch(css, /^color:/m);
assert.doesNotMatch(css, /^background-color:/m);
});
test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => {
const css = serializeSubtitleCssDeclarations('secondary', {
'subtitleStyle.secondary.fontFamily': 'Noto Sans, sans-serif',
'subtitleStyle.secondary.fontSize': 24,
'subtitleStyle.secondary.fontColor': '#cad3f5',
'subtitleStyle.secondary.backgroundColor': 'transparent',
'subtitleStyle.secondary.css': {
'text-transform': 'uppercase',
},
});
assert.match(css, /font-family: Noto Sans, sans-serif;/);
assert.match(css, /font-size: 24px;/);
assert.match(css, /text-transform: uppercase;/);
assert.doesNotMatch(css, /^color:/m);
assert.doesNotMatch(css, /^background-color:/m);
});
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
assert.deepEqual(
parseSubtitleCssDeclarations(`
font-size: 40px;
text-wrap: balance;
-webkit-text-stroke: 1px black;
--subtitle-outline: 1px;
`),
{
ok: true,
declarations: {
'font-size': '40px',
'text-wrap': 'balance',
'-webkit-text-stroke': '1px black',
'--subtitle-outline': '1px',
},
},
);
});
test('parseSubtitleCssDeclarations rejects selectors and malformed declarations', () => {
assert.equal(parseSubtitleCssDeclarations('#subtitleRoot { font-size: 40px; }').ok, false);
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
});
test('getSubtitleCssManagedConfigPaths excludes color controls', () => {
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
assert.ok(
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
false,
);
});
+302
View File
@@ -0,0 +1,302 @@
import type { ConfigSettingsSnapshotValue } from '../types/settings';
export type SubtitleCssScope = 'primary' | 'secondary';
type LegacyCssDeclaration = {
property: string;
primaryPath: string;
secondaryPath: string;
format?: (value: unknown) => string | undefined;
};
export type SubtitleCssParseResult =
| { ok: true; declarations: Record<string, string> }
| { ok: false; error: string };
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
{
property: 'font-family',
primaryPath: 'subtitleStyle.fontFamily',
secondaryPath: 'subtitleStyle.secondary.fontFamily',
},
{
property: 'font-size',
primaryPath: 'subtitleStyle.fontSize',
secondaryPath: 'subtitleStyle.secondary.fontSize',
format: formatCssLengthLikeValue,
},
{
property: 'font-weight',
primaryPath: 'subtitleStyle.fontWeight',
secondaryPath: 'subtitleStyle.secondary.fontWeight',
},
{
property: 'font-style',
primaryPath: 'subtitleStyle.fontStyle',
secondaryPath: 'subtitleStyle.secondary.fontStyle',
},
{
property: 'line-height',
primaryPath: 'subtitleStyle.lineHeight',
secondaryPath: 'subtitleStyle.secondary.lineHeight',
},
{
property: 'letter-spacing',
primaryPath: 'subtitleStyle.letterSpacing',
secondaryPath: 'subtitleStyle.secondary.letterSpacing',
},
{
property: 'word-spacing',
primaryPath: 'subtitleStyle.wordSpacing',
secondaryPath: 'subtitleStyle.secondary.wordSpacing',
},
{
property: 'font-kerning',
primaryPath: 'subtitleStyle.fontKerning',
secondaryPath: 'subtitleStyle.secondary.fontKerning',
},
{
property: 'text-rendering',
primaryPath: 'subtitleStyle.textRendering',
secondaryPath: 'subtitleStyle.secondary.textRendering',
},
{
property: 'text-shadow',
primaryPath: 'subtitleStyle.textShadow',
secondaryPath: 'subtitleStyle.secondary.textShadow',
},
{
property: 'backdrop-filter',
primaryPath: 'subtitleStyle.backdropFilter',
secondaryPath: 'subtitleStyle.secondary.backdropFilter',
},
{
property: 'color',
primaryPath: 'subtitleStyle.fontColor',
secondaryPath: 'subtitleStyle.secondary.fontColor',
},
{
property: 'background-color',
primaryPath: 'subtitleStyle.backgroundColor',
secondaryPath: 'subtitleStyle.secondary.backgroundColor',
},
];
const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/;
export function getSubtitleCssPath(scope: SubtitleCssScope): string {
return scope === 'primary' ? 'subtitleStyle.css' : 'subtitleStyle.secondary.css';
}
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
return LEGACY_CSS_DECLARATIONS.map((declaration) =>
scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath,
);
}
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
if (path === 'subtitleStyle.css') return 'primary';
if (path === 'subtitleStyle.secondary.css') return 'secondary';
return null;
}
export function serializeSubtitleCssDeclarations(
scope: SubtitleCssScope,
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
): string {
const declarations = new Map<string, string>();
for (const declaration of LEGACY_CSS_DECLARATIONS) {
const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath;
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
if (formatted !== undefined) {
declarations.set(declaration.property, formatted);
}
}
const cssObject = normalizeCssDeclarationRecord(values[getSubtitleCssPath(scope)]);
for (const [property, value] of Object.entries(cssObject)) {
declarations.set(normalizeCssPropertyName(property), value);
}
return [...declarations.entries()]
.map(([property, value]) => `${property}: ${value};`)
.join('\n');
}
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
const trimmed = text.trim();
if (trimmed.length === 0) {
return { ok: true, declarations: {} };
}
if (/[{}]/.test(trimmed)) {
return {
ok: false,
error: 'Enter CSS declarations only, without selectors or braces.',
};
}
const declarations: Record<string, string> = {};
for (const rawDeclaration of splitCssDeclarations(trimmed)) {
const declaration = rawDeclaration.trim();
if (declaration.length === 0) continue;
const colonIndex = findTopLevelColon(declaration);
if (colonIndex <= 0) {
return { ok: false, error: `Invalid CSS declaration: ${declaration}` };
}
const property = normalizeCssPropertyName(declaration.slice(0, colonIndex).trim());
const value = declaration.slice(colonIndex + 1).trim();
if (!CSS_PROPERTY_PATTERN.test(property)) {
return { ok: false, error: `Invalid CSS property: ${property}` };
}
if (value.length === 0) {
return { ok: false, error: `Missing CSS value for ${property}.` };
}
declarations[property] = value;
}
return { ok: true, declarations };
}
function normalizeCssDeclarationRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const declarations: Record<string, string> = {};
for (const [property, rawValue] of Object.entries(value)) {
if (typeof rawValue !== 'string') continue;
const trimmed = rawValue.trim();
if (trimmed.length === 0) continue;
declarations[property] = trimmed;
}
return declarations;
}
function normalizeCssPropertyName(property: string): string {
const trimmed = property.trim();
if (trimmed.startsWith('--')) return trimmed;
if (trimmed.includes('-')) return trimmed.toLowerCase();
const kebab = trimmed
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/^Webkit-/, '-webkit-')
.toLowerCase();
return kebab.startsWith('webkit-') ? `-${kebab}` : kebab;
}
function formatCssLengthLikeValue(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return `${value}px`;
}
return formatCssPrimitiveValue(value);
}
function formatCssPrimitiveValue(value: unknown): string | undefined {
if (value === null || value === undefined || typeof value === 'object') {
return undefined;
}
const text = String(value).trim();
return text.length > 0 ? text : undefined;
}
function splitCssDeclarations(text: string): string[] {
const declarations: string[] = [];
let current = '';
let quote: '"' | "'" | null = null;
let parenDepth = 0;
let escaping = false;
for (const char of text) {
if (escaping) {
current += char;
escaping = false;
continue;
}
if (char === '\\') {
current += char;
escaping = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
current += char;
quote = char;
continue;
}
if (char === '(') {
parenDepth += 1;
current += char;
continue;
}
if (char === ')') {
parenDepth = Math.max(0, parenDepth - 1);
current += char;
continue;
}
if (char === ';' && parenDepth === 0) {
declarations.push(current);
current = '';
continue;
}
current += char;
}
declarations.push(current);
return declarations;
}
function findTopLevelColon(text: string): number {
let quote: '"' | "'" | null = null;
let parenDepth = 0;
let escaping = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (escaping) {
escaping = false;
continue;
}
if (char === '\\') {
escaping = true;
continue;
}
if (quote) {
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') {
parenDepth += 1;
continue;
}
if (char === ')') {
parenDepth = Math.max(0, parenDepth - 1);
continue;
}
if (char === ':' && parenDepth === 0) {
return i;
}
}
return -1;
}
+1
View File
@@ -86,6 +86,7 @@ export interface AnkiConnectConfig {
color?: string; color?: string;
}; };
nPlusOne?: { nPlusOne?: {
enabled?: boolean;
nPlusOne?: string; nPlusOne?: string;
minSentenceWords?: number; minSentenceWords?: number;
}; };
+1
View File
@@ -214,6 +214,7 @@ export interface ResolvedConfig {
decks: Record<string, string[]>; decks: Record<string, string[]>;
}; };
nPlusOne: { nPlusOne: {
enabled: boolean;
minSentenceWords: number; minSentenceWords: number;
}; };
behavior: { behavior: {
+3 -1
View File
@@ -26,7 +26,8 @@ export type ConfigSettingsControl =
| 'anki-note-type' | 'anki-note-type'
| 'anki-field' | 'anki-field'
| 'mpv-keybindings' | 'mpv-keybindings'
| 'color-list'; | 'color-list'
| 'css-declarations';
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart'; export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
@@ -45,6 +46,7 @@ export interface ConfigSettingsField {
advanced?: boolean; advanced?: boolean;
secret?: boolean; secret?: boolean;
legacyHidden?: boolean; legacyHidden?: boolean;
settingsHidden?: boolean;
} }
export type ConfigSettingsSnapshotValue = unknown; export type ConfigSettingsSnapshotValue = unknown;
+2
View File
@@ -70,6 +70,7 @@ export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
export interface SubtitleStyleConfig { export interface SubtitleStyleConfig {
primaryDefaultMode?: PrimarySubMode; primaryDefaultMode?: PrimarySubMode;
css?: Record<string, string>;
enableJlpt?: boolean; enableJlpt?: boolean;
preserveLineBreaks?: boolean; preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean; autoPauseVideoOnHover?: boolean;
@@ -110,6 +111,7 @@ export interface SubtitleStyleConfig {
bandedColors?: [string, string, string, string, string]; bandedColors?: [string, string, string, string, string];
}; };
secondary?: { secondary?: {
css?: Record<string, string>;
fontFamily?: string; fontFamily?: string;
fontSize?: number; fontSize?: number;
fontColor?: string; fontColor?: string;