fix: custom css editor page (#2125)

* feat(subtitles): add custom CSS and preset templates to the subtitle style page

The subtitle style page could only set fonts, sizes, weights and colours, so
effects like blurring the translation while training your listening had nowhere
to live. A custom CSS row at the bottom of that page now opens an editor with
the same live preview above it, plus a preset dropdown carrying three ready-made
templates — blur translation, dashed translation, and dim original — that append
into the editor so they can be stacked.

The picked font, size, weight and colour now reach the subtitle lines as inline
CSS variables consumed by an unlayered base rule, rather than as inline styles.
Inline styles outrank every stylesheet rule, so without this a user writing
`.subtitles-main { color: red }` would get nothing and have to discover
`!important` on their own.

The preview renders in a shadow root, the same way the real overlay does. That
is what contains the CSS being previewed: a rewriting pass over the selectors
leaks through at-rules that carry none, cannot stop `@keyframes` names from
hijacking the settings page's own animations, and adds specificity production
never adds. Layout is the one thing a shadow root does not contain, so the
light-DOM wrapper carries the containment for `position: fixed` and runaway
heights.

Config gains `videoSubtitles.style.customCSS`, with the v099 to v100 migration
and fixtures that go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(translation): stop custom CSS from breaking the settings page it is previewed on

The custom CSS written for translated text was injected into the options page's
own document to render the preview, so it styled the settings UI as well. A rule
as ordinary as `* { display: none }` left the options page blank on every load —
editor and sidebar included — and because the CSS is saved in config it came
back on the next load too, with no way out through the UI.

The preview now renders inside a same-origin `srcdoc` frame. A frame is the only
container that is also a Document, which is what production injects into, so
unlike a shadow root it agrees with a real page on `:root` variables, `body`
selectors, `@font-face` and `@property` — a shadow root would hide the first
four and answer to `:host`, which production never does. A bounded-height frame
is also its own viewport, so `position: fixed` and runaway heights stay inside
it without any containment tricks.

Two supporting changes, both no-ops on a real page: `decorateTranslationNode`
resolves the node's own document rather than the ambient one, and the injector's
Document check uses `nodeType` because an iframe's Document fails `instanceof`
when tested from the parent realm.

The frame follows the extension's theme and reads its colours from the same
`--rf-*` tokens as the page around it, and the sample now inherits an ordinary
page's 16px rather than the settings page's 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(translation): apply the removal of custom CSS without needing a reload

Switching translated text back to a preset style, or emptying the custom CSS
box, only changed the marker attribute on the node — the stylesheet itself
stayed adopted until the page was reloaded. Anything the preset does not set
kept the old styling: the `border` preset sets no colour, so a cleared `color`
rule went on applying.

A reload eventually hid this on a real page. The options preview never reloads,
so there it reads as deleting the CSS having done nothing at all.

The withdrawal is guarded, so a root that never had custom CSS does not acquire
an empty stylesheet just for being styled by a preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ananaBMaster
2026-08-24 23:12:38 -07:00
committed by GitHub
parent fe2957c84c
commit d0fc8e57fa
39 changed files with 2932 additions and 71 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@read-frog/extension": patch
---
fix(translation): apply the removal of custom CSS without needing a reload
Switching translated text back to a preset style, or emptying the custom CSS box, only changed the marker on the node — the stylesheet itself stayed applied until the page was reloaded. Anything the preset did not set kept the old styling, so clearing a `color` rule under the border preset, for instance, left the text its old colour. The stylesheet is now withdrawn along with the marker.
@@ -0,0 +1,9 @@
---
"@read-frog/extension": patch
---
fix(translation): stop custom CSS from breaking the settings page it is previewed on
The custom CSS written for translated text was injected into the options page's own document to render the preview, so it styled the settings UI as well. A rule as ordinary as `* { display: none }` left the options page blank on every load — editor and sidebar included — with the CSS saved in config and no way back through the UI.
The preview now renders inside a same-origin frame, so the CSS reaches the sample text and nothing else. Because a frame is a document, everything the CSS could do on a real page it still does here — `:root` variables, `body` selectors, `@font-face` and `@property` all behave as they will in the wild, and the sample now inherits an ordinary page's 16px rather than the settings page's 14px.
+11
View File
@@ -0,0 +1,11 @@
---
"@read-frog/extension": patch
---
feat(subtitles): add custom CSS and preset templates to the subtitle style page
The subtitle style page could only set fonts, sizes, weights and colours, so effects like blurring the translation while training your listening had nowhere to live. A custom CSS row at the bottom of that page now opens an editor with the same live preview above it, plus a preset dropdown carrying three ready-made templates — blur translation, dashed translation, and dim original — that append into the editor so they can be stacked.
The picked font, size, weight and colour now reach the subtitle lines as CSS variables rather than inline styles, so custom CSS can override them without `!important`.
The preview renders in a shadow root, the same way the real overlay does, so the CSS being previewed reaches the preview and nothing else on the settings page.
+20
View File
@@ -0,0 +1,20 @@
/**
* Base styling for the two subtitle lines.
*
* The font, size, weight and colour a user picks in the style editor arrive as inline custom
* properties on the line itself, and these rules are what turn them into real declarations.
* Writing the four properties inline directly would be simpler, but inline styles outrank every
* stylesheet rule — custom CSS could then never change a subtitle's colour without `!important`.
*
* Deliberately not inside a Tailwind layer: unlayered rules beat any layered rule regardless of
* specificity, which is what keeps `.text-xl` on the line from taking `font-size` back. Custom CSS
* is unlayered too and injected later, so it still wins over this.
*/
.subtitles-main,
.subtitles-translation {
font-family: var(--rf-subtitle-font-family, inherit);
font-size: var(--rf-subtitle-font-size, 1em);
color: var(--rf-subtitle-color, inherit);
font-weight: var(--rf-subtitle-font-weight, inherit);
}
+1
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "./glass-badge.css";
@import "./subtitle-lines.css";
@source not "../../**/__tests__";
@source not "../../**/*.test.*";
@@ -25,6 +25,7 @@ export const ROUTE_DEFS = [
{ path: "/page-translation/translation-control/site-rules" },
{ path: "/page-translation/translation-queue" },
{ path: "/video-subtitles/style" },
{ path: "/video-subtitles/style/custom-css" },
{ path: "/video-subtitles/prompts" },
{ path: "/video-subtitles/subtitles-queue" },
] as const
+6
View File
@@ -86,6 +86,11 @@ const ExtensionActivationPage = lazy(() =>
default: module.ExtensionActivationPage,
})),
)
const SubtitlesCustomCssPage = lazy(() =>
import("./pages/video-subtitles/subtitles-style/custom-css").then((module) => ({
default: module.SubtitlesCustomCssPage,
})),
)
const SubtitlesStylePage = lazy(() =>
import("./pages/video-subtitles/subtitles-style/style-editor").then((module) => ({
default: module.SubtitlesStylePage,
@@ -127,6 +132,7 @@ const ROUTE_COMPONENTS: Record<RoutePath, ComponentType> = {
"/page-translation/translation-control/site-rules": SiteRulesPage,
"/page-translation/translation-queue": TranslationQueuePage,
"/video-subtitles/style": SubtitlesStylePage,
"/video-subtitles/style/custom-css": SubtitlesCustomCssPage,
"/video-subtitles/prompts": SubtitlesCustomPromptsPage,
"/video-subtitles/subtitles-queue": SubtitlesQueuePage,
}
@@ -456,6 +456,14 @@ export const SEARCH_ITEMS: SearchItem[] = [
descriptionKey: "options.videoSubtitles.style.description",
pageKey: "options.videoSubtitles.title",
},
{
// A page below the style page, drilled into from the custom CSS row at its bottom.
sectionId: "subtitles-custom-css",
route: "/video-subtitles/style/custom-css",
titleKey: "options.videoSubtitles.style.customCSS.title",
descriptionKey: "options.videoSubtitles.style.customCSS.description",
pageKey: "options.videoSubtitles.title",
},
{
// Its own page, drilled into from the Video Subtitles page's Custom prompts section.
sectionId: "subtitles-custom-prompts",
@@ -1,14 +1,24 @@
import { useState } from "react"
import { i18n } from "@/utils/i18n"
import { ConfigDetailSection } from "../../../../components/config-detail-section"
import { PageLayout } from "../../../../components/page-layout"
import { PREVIEW_TEXT, StylePreview } from "../style-preview"
import { CSSEditor } from "./css-editor"
import { PreviewPanel } from "./preview-panel"
import { PreviewControls } from "./preview-controls"
/**
* The CSS editor and its preview, drilled into from the Translation Display Style section. An
* editor tall enough to write rules in cannot share a row with anything, so it gets a page.
*
* The preview leads: it is the result being worked towards, and putting it first keeps it in view
* while the rules that produce it are written below. The sample it renders is described by the
* controls further down, so the state for those lives here rather than beside them.
*/
export function CustomCssPage() {
const [language, setLanguage] = useState("zh")
const [dir, setDir] = useState<"ltr" | "rtl">("ltr")
const [text, setText] = useState(PREVIEW_TEXT)
return (
<PageLayout
title={i18n.t("options.translation.title")}
@@ -20,8 +30,16 @@ export function CustomCssPage() {
<span id="custom-css">{i18n.t("options.translation.translationStyle.cssEditor")}</span>
}
>
<StylePreview text={text} language={language} dir={dir} />
<CSSEditor />
<PreviewPanel />
<PreviewControls
language={language}
onLanguageChange={setLanguage}
dir={dir}
onDirChange={setDir}
text={text}
onTextChange={setText}
/>
</ConfigDetailSection>
</PageLayout>
)
@@ -1,5 +1,4 @@
import { LANG_CODE_ISO6391_OPTIONS } from "@read-frog/definitions"
import { useState } from "react"
import { Field, FieldLabel } from "@/components/ui/base-ui/field"
import {
Select,
@@ -11,17 +10,29 @@ import {
} from "@/components/ui/base-ui/select"
import { Textarea } from "@/components/ui/base-ui/textarea"
import { i18n } from "@/utils/i18n"
import { PREVIEW_TEXT, StylePreview } from "../style-preview"
export interface PreviewControlsProps {
language: string
onLanguageChange: (language: string) => void
dir: "ltr" | "rtl"
onDirChange: (dir: "ltr" | "rtl") => void
text: string
onTextChange: (text: string) => void
}
/**
* The preview, plus the knobs only custom CSS needs: rules can key off language and direction,
* so the writer has to be able to point the sample at either one.
* The knobs only custom CSS needs: rules can key off language and direction, so the writer has to
* be able to point the sample at either one. They hold no state the preview they drive sits at
* the top of the page, above the editor, so the page owns it.
*/
export function PreviewPanel() {
const [language, setLanguage] = useState("zh")
const [dir, setDir] = useState<"ltr" | "rtl">("ltr")
const [text, setText] = useState(PREVIEW_TEXT)
export function PreviewControls({
language,
onLanguageChange,
dir,
onDirChange,
text,
onTextChange,
}: PreviewControlsProps) {
return (
<div className="flex w-full flex-col gap-6">
<div className="grid grid-cols-2 gap-4">
@@ -32,7 +43,7 @@ export function PreviewPanel() {
<Select
value={language}
onValueChange={(value) => {
if (value) setLanguage(value)
if (value) onLanguageChange(value)
}}
>
<SelectTrigger id="language-select" className="w-full">
@@ -54,7 +65,7 @@ export function PreviewPanel() {
<FieldLabel htmlFor="dir-select">
{i18n.t("options.translation.translationStyle.stylePreviewDirection")}
</FieldLabel>
<Select value={dir} onValueChange={(value) => setDir(value as "ltr" | "rtl")}>
<Select value={dir} onValueChange={(value) => onDirChange(value as "ltr" | "rtl")}>
<SelectTrigger id="dir-select" className="w-full">
<SelectValue />
</SelectTrigger>
@@ -75,12 +86,10 @@ export function PreviewPanel() {
<Textarea
id="preview-text"
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => onTextChange(e.target.value)}
className="min-h-20"
/>
</Field>
<StylePreview text={text} language={language} dir={dir} />
</div>
)
}
@@ -0,0 +1,178 @@
import type { ReactNode } from "react"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/utils/styles/utils"
/** The srcdoc's own URL once the frame has navigated to it. `about:blank` is the document that
* exists first, and it is a different one — see the mount effect. */
const SRCDOC_URL = "about:srcdoc"
const SHELL = "<!doctype html><html><head></head><body></body></html>"
/** Tall enough for the one or two lines this normally holds, once padding is counted. */
const MIN_HEIGHT = 64
/**
* The ceiling is the containment. A frame sized purely to its content would hand back the one thing
* the iframe buys for free — `height: 100000px` inside would grow the frame, and the frame would
* grow the settings page, which is exactly the lockout this replaced.
*/
const MAX_HEIGHT = 320
/**
* Typography is deliberately plain — custom CSS is written against ordinary web pages, so the frame
* should read as the least surprising one rather than as this settings page. The previous preview
* inherited the options page's Inter and its 14px `text-sm`, neither of which a translated node
* ever sees in the wild.
*
* Colour is the opposite: it follows the extension's own theme, because a white card in a dark
* settings page reads as a rendering fault. The values come from the same `--rf-*` tokens the page
* around it uses, so the two always agree.
*
* `color-scheme` follows the theme with them, which decides what `light-dark()` resolves to — and
* the CSS shipped as the starting example in every locale leads with `light-dark()`. Following the
* theme means the preview shows the branch matching the mode being looked at, and switching the
* extension's theme is how to see the other one. Pinning it light instead would make the dark
* branch unpreviewable, which is worse than either being "the" answer.
*/
/**
* The theme as it is actually painted, read off the element `applyTheme` writes it to.
*
* Read from the DOM rather than from the theme context, because this is the same element the tokens
* below are read from — one source of truth, and it cannot disagree with what is on screen. It also
* keeps the preview renderable without a theme provider above it, which the sections that embed it
* do not otherwise need.
*/
function useAppliedTheme(): "light" | "dark" {
return useSyncExternalStore(
(onChange) => {
const observer = new MutationObserver(onChange)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
return () => observer.disconnect()
},
() => (document.documentElement.classList.contains("dark") ? "dark" : "light"),
)
}
function buildFrameCSS(theme: "light" | "dark"): string {
const root = getComputedStyle(document.documentElement)
const background = root.getPropertyValue("--rf-background").trim() || "#fff"
const foreground = root.getPropertyValue("--rf-foreground").trim() || "#1a1a1a"
return `
html { background: ${background}; color: ${foreground}; color-scheme: ${theme}; }
body { margin: 0; padding: 16px; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px; line-height: 1.6; }
`
}
interface PagePreviewFrameProps {
children: ReactNode
className?: string
}
/**
* Renders the translated-node preview inside a same-origin `srcdoc` iframe.
*
* The CSS shown here is the user's, and production injects it into the host page's *document* —
* so until now this preview injected it into the settings page's document, where a rule as ordinary
* as `* { display: none }` left the options UI blank on every load, editor and sidebar included,
* with the offending CSS saved in config and no way back through the UI.
*
* An iframe is the only container that is also a Document, which is why it is used here rather than
* the shadow root the subtitle preview uses: a shadow tree has no `:root` and no `body`, ignores
* `@property` and `@font-face`, and answers to `:host`, so it would quietly disagree with production
* on every one of those — in both directions. The frame agrees with production and contains
* everything, layout included: a fixed-height frame is its own viewport, so `position: fixed` and a
* runaway height stay inside it with no containment tricks.
*
* The frame follows its content only between a floor and a ceiling — an unbounded content size
* would give that last property back.
*/
export function PagePreviewFrame({ children, className }: PagePreviewFrameProps) {
const frameRef = useRef<HTMLIFrameElement>(null)
const [body, setBody] = useState<HTMLElement | null>(null)
const [height, setHeight] = useState(MIN_HEIGHT)
const theme = useAppliedTheme()
useEffect(() => {
const frame = frameRef.current
if (!frame) return undefined
// The frame starts on an `about:blank` document that already reports `readyState: "complete"`,
// and is then replaced by a different document for the srcdoc. Keying off readiness portals the
// children into the throwaway one and the preview comes up permanently empty; the URL is what
// actually distinguishes them.
const attach = () => {
const doc = frame.contentDocument
if (!doc || doc.URL !== SRCDOC_URL) return false
setBody(doc.body)
return true
}
if (attach()) return undefined
frame.addEventListener("load", attach)
return () => {
frame.removeEventListener("load", attach)
setBody(null)
}
}, [])
// Rewritten rather than written once: the theme can change while the page is open, and the frame
// has to follow it. Injected ahead of anything the user writes, so their CSS still wins.
useEffect(() => {
if (!body) return
const doc = body.ownerDocument
let style = doc.getElementById("read-frog-preview-base")
if (!style) {
style = doc.createElement("style")
style.id = "read-frog-preview-base"
doc.head.prepend(style)
}
style.textContent = buildFrameCSS(theme)
}, [body, theme])
// Follow the content between the two bounds, so a single line does not sit in a tall empty box.
// `body` is measured rather than `documentElement`, whose scrollHeight is floored at the frame's
// own viewport and would therefore never let the frame shrink again — and its rect rather than
// its `scrollHeight`, which is a rounded integer: a body 73.59px tall reports 73, one pixel short
// of its own content, and the frame comes up with a scrollbar it does not need.
useEffect(() => {
if (!body) return undefined
const measure = () => {
const content = Math.ceil(body.getBoundingClientRect().height)
setHeight(Math.min(Math.max(content, MIN_HEIGHT), MAX_HEIGHT))
}
measure()
const view = body.ownerDocument.defaultView
if (!view?.ResizeObserver) return undefined
const observer = new view.ResizeObserver(measure)
observer.observe(body)
return () => observer.disconnect()
}, [body])
return (
// The border lives out here rather than on the frame. `box-sizing: border-box` would otherwise
// take it out of the height set below, leaving the frame's viewport two pixels shorter than the
// content measured to fit it — which is a scrollbar on a preview that fits perfectly.
<div className={cn("w-full overflow-hidden rounded-md border bg-background", className)}>
<iframe
ref={frameRef}
title="preview"
srcDoc={SHELL}
// `allow-same-origin` and nothing else: without it the frame gets an opaque origin and
// `contentDocument` goes out of reach, and every other capability — scripts, forms, popups,
// top-level navigation — stays off, which is everything a stylesheet preview needs.
sandbox="allow-same-origin"
style={{ height }}
className="block w-full border-0"
>
{body && createPortal(children, body)}
</iframe>
</div>
)
}
@@ -1,10 +1,11 @@
import { useAtomValue } from "jotai"
import { useEffect, useRef } from "react"
import { useEffect, useState } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { BLOCK_CONTENT_CLASS, CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels"
import { decorateTranslationNode } from "@/utils/host/translate/ui/decorate-translation"
import { i18n } from "@/utils/i18n"
import { cn } from "@/utils/styles/utils"
import { PagePreviewFrame } from "./page-preview-frame"
/** The sample the preview shows until the CSS editor's own text box replaces it. */
export const PREVIEW_TEXT = "神谷先生不是在对抗世界,而是在对抗可能让世界为之侧目的事物。"
@@ -25,25 +26,32 @@ export function StylePreview({
className?: string
}) {
const { translationNodeStyle } = useAtomValue(configFieldsAtomMap.pageTranslation)
const blockContentRef = useRef<HTMLSpanElement>(null)
// A callback ref rather than `useRef`: the node is portalled into an iframe that mounts a frame
// later than this component does, so the effect has to re-run when it finally appears.
const [blockContent, setBlockContent] = useState<HTMLSpanElement | null>(null)
useEffect(() => {
if (blockContentRef.current) {
void decorateTranslationNode(blockContentRef.current, translationNodeStyle)
if (blockContent) {
void decorateTranslationNode(blockContent, translationNodeStyle)
}
}, [translationNodeStyle])
}, [blockContent, translationNodeStyle])
return (
<div className={cn("flex w-full flex-col gap-2", className)}>
<span className="text-sm leading-5 font-medium">
{i18n.t("options.translation.translationStyle.preview")}
</span>
<div id="style-preview" className="flex w-full flex-col gap-2 rounded-md border p-4">
<span className={CONTENT_WRAPPER_CLASS} lang={language} dir={dir}>
<span className={`text-sm ${BLOCK_CONTENT_CLASS}`} ref={blockContentRef}>
{text}
<div id="style-preview" className="w-full">
<PagePreviewFrame>
{/* No `text-sm`: production puts only the block-content class on this node, so pinning a
font size here made the preview disagree with every real page about how big the text
is. Inside the frame it inherits an ordinary 16px instead. */}
<span className={CONTENT_WRAPPER_CLASS} lang={language} dir={dir}>
<span className={BLOCK_CONTENT_CLASS} ref={setBlockContent}>
{text}
</span>
</span>
</span>
</PagePreviewFrame>
</div>
</div>
)
@@ -0,0 +1,126 @@
/**
* Subtitle custom CSS editor.
*
* The sibling of the page-translation editor, with one difference that shows: the draft lives on
* the page rather than here, because the preview above renders from it live and the preset dropdown
* writes into it. Saving is still explicit — a stylesheet is worth committing on purpose.
*/
import { deepmerge } from "deepmerge-ts"
import { useAtom } from "jotai"
import { useMemo } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { Field } from "@/components/ui/base-ui/field"
import { CSSCodeEditor } from "@/components/ui/css-code-editor"
import { env } from "@/env"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { MAX_CUSTOM_CSS_LENGTH } from "@/types/config/translate"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { lintCSS } from "@/utils/css/lint-css"
import { i18n } from "@/utils/i18n"
import { cn } from "@/utils/styles/utils"
interface CSSEditorProps {
value: string
onChange: (value: string) => void
}
export function CSSEditor({ value, onChange }: CSSEditorProps) {
const [videoSubtitlesConfig, setVideoSubtitlesConfig] = useAtom(
configFieldsAtomMap.videoSubtitles,
)
const savedCSS = videoSubtitlesConfig.style.customCSS ?? ""
const debouncedValue = useDebouncedValue(value, 500)
const syntaxCheck = useMemo(() => {
if (!debouncedValue.trim()) {
return { valid: true, errors: [] }
}
return lintCSS(debouncedValue)
}, [debouncedValue])
const hasLengthError = debouncedValue.length > MAX_CUSTOM_CSS_LENGTH
const hasSyntaxError = !syntaxCheck.valid
const isValidating = value !== debouncedValue
const hasChanges = value !== savedCSS
const handleSave = () => {
if (hasSyntaxError || hasLengthError || isValidating || !hasChanges) {
return
}
// Cleared back to empty means off, and the schema spells that `null` rather than `""`.
const customCSS = value.trim() ? value : null
void setVideoSubtitlesConfig(deepmerge(videoSubtitlesConfig, { style: { customCSS } }))
}
return (
<Field>
{/* The section heading already names this editor, so the row carries only the docs link. */}
<div className="flex items-start justify-end">
<a
href={`${env.WXT_WEBSITE_URL}/docs/custom-css`}
className="text-xs text-link hover:opacity-90"
target="_blank"
rel="noreferrer"
>
{i18n.t("options.videoSubtitles.style.customCSS.editor.docsLink")}
</a>
</div>
<CSSCodeEditor
value={value}
onChange={onChange}
hasError={hasSyntaxError || hasLengthError}
placeholder={i18n.t("options.videoSubtitles.style.customCSS.editor.placeholder")}
className="max-h-[400px] min-h-[200px] overflow-y-auto"
/>
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"text-sm text-green-500",
isValidating && "text-muted-foreground",
(hasSyntaxError || hasLengthError) && "text-destructive",
)}
>
{value.trim().length > 0
? getValidationMessage(isValidating, hasSyntaxError, hasLengthError, hasChanges)
: ""}
</div>
<Button
onClick={handleSave}
disabled={isValidating || hasSyntaxError || hasLengthError || !hasChanges}
>
{hasChanges
? i18n.t("options.videoSubtitles.style.customCSS.editor.saveButton")
: i18n.t("options.videoSubtitles.style.customCSS.editor.savedButton")}
</Button>
</div>
</Field>
)
}
function getValidationMessage(
isValidating: boolean,
hasSyntaxError: boolean,
hasLengthError: boolean,
hasChanges: boolean,
) {
if (isValidating) {
return i18n.t("options.videoSubtitles.style.customCSS.editor.validation.validating")
}
if (hasSyntaxError) {
return i18n.t("options.videoSubtitles.style.customCSS.editor.validation.syntaxError")
}
if (hasLengthError) {
return i18n.t("options.videoSubtitles.style.customCSS.editor.validation.tooLong")
}
if (!hasChanges) {
return i18n.t("options.videoSubtitles.style.customCSS.editor.validation.saved")
}
return i18n.t("options.videoSubtitles.style.customCSS.editor.validation.valid")
}
@@ -0,0 +1,61 @@
import { useAtomValue } from "jotai"
import { useCallback, useState } from "react"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { i18n } from "@/utils/i18n"
import { ConfigDetailSection } from "../../../../components/config-detail-section"
import { ConfigItem } from "../../../../components/config-item"
import { PageLayout } from "../../../../components/page-layout"
import { SubtitlesPreview } from "../style-editor/subtitles-preview"
import { CSSEditor } from "./css-editor"
import { PresetTemplateSelect } from "./preset-template-select"
/**
* Custom CSS for the subtitle lines, drilled into from the subtitle style page. The preview above
* is the same one that page shows, but fed the unsaved draft — the point of writing CSS here is
* watching it land, and a Save round trip between every keystroke and the result would hide that.
*
* The draft goes to the preview verbatim, including while it is half-typed. That is what the real
* overlay would do with it, and the preview lives in a shadow root for exactly that reason: there
* is no rewriting pass that a partial rule could confuse, and nothing it can reach on this page.
*/
export function SubtitlesCustomCssPage() {
const { style } = useAtomValue(configFieldsAtomMap.videoSubtitles)
const [draft, setDraft] = useState(style.customCSS ?? "")
// Debounced only to keep the shadow root's stylesheet from being rebuilt on every keystroke.
const previewCSS = useDebouncedValue(draft, 300)
// Presets stack rather than replace: they touch different lines, so blurring the translation
// while dimming the original is a combination worth being able to click twice for.
const appendPreset = useCallback((preset: string) => {
setDraft((current) => {
const trimmed = current.replace(/\s+$/, "")
return trimmed ? `${trimmed}\n\n${preset}\n` : `${preset}\n`
})
}, [])
return (
<PageLayout
title={i18n.t("options.videoSubtitles.title")}
description={i18n.t("options.videoSubtitles.pageDescription")}
>
<ConfigDetailSection
backTo="/video-subtitles/style"
title={
<span id="subtitles-custom-css">
{i18n.t("options.videoSubtitles.style.customCSS.title")}
</span>
}
>
<SubtitlesPreview previewCSS={previewCSS} />
<ConfigItem
title={i18n.t("options.videoSubtitles.style.customCSS.presetTemplate")}
description={i18n.t("options.videoSubtitles.style.customCSS.presetTemplateDescription")}
>
<PresetTemplateSelect onApply={appendPreset} />
</ConfigItem>
<CSSEditor value={draft} onChange={setDraft} />
</ConfigDetailSection>
</PageLayout>
)
}
@@ -0,0 +1,57 @@
import type { SubtitleCssPresetId } from "@/utils/constants/subtitles"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
import { SUBTITLE_CSS_PRESET_IDS, SUBTITLE_CSS_PRESETS } from "@/utils/constants/subtitles"
import { i18n } from "@/utils/i18n"
import { SELECT_CONTENT_PROPS } from "../../../../components/select-content-props"
interface PresetTemplateSelectProps {
/** Receives the preset's CSS, already carrying a comment naming it. */
onApply: (css: string) => void
}
/**
* Applies a preset into the editor and forgets it.
*
* Holding the chosen preset as config instead would let it drift: a preset is a starting point the
* user is expected to edit, and a dropdown still reading "Blur translation" after the blur has been
* rewritten into an outline names something that is no longer there. So the value stays empty and
* the control reads as the action it is.
*/
export function PresetTemplateSelect({ onApply }: PresetTemplateSelectProps) {
return (
<Select
value={null}
onValueChange={(preset: SubtitleCssPresetId | null) => {
if (!preset) return
onApply(`/* ${presetLabel(preset)} */\n${SUBTITLE_CSS_PRESETS[preset]}`)
}}
>
<SelectTrigger size="sm">
<SelectValue render={<span />}>
{i18n.t("options.videoSubtitles.style.customCSS.presetPlaceholder")}
</SelectValue>
</SelectTrigger>
<SelectContent {...SELECT_CONTENT_PROPS}>
<SelectGroup>
{SUBTITLE_CSS_PRESET_IDS.map((preset) => (
<SelectItem key={preset} value={preset}>
{presetLabel(preset)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
/** Localised at append time, so the comment left in the CSS is in the language it was added from. */
function presetLabel(preset: SubtitleCssPresetId): string {
return i18n.t(`options.videoSubtitles.style.customCSS.presets.${preset}`)
}
@@ -1,5 +1,6 @@
import { i18n } from "@/utils/i18n"
import { ConfigDetailSection } from "../../../../components/config-detail-section"
import { ConfigNavItem } from "../../../../components/config-nav-item"
import { PageLayout } from "../../../../components/page-layout"
import { GeneralSettings } from "./general-settings"
import { MainSubtitlesStyle } from "./main-subtitles-style"
@@ -8,7 +9,8 @@ import { TranslationSubtitlesStyle } from "./translation-subtitles-style"
/**
* The subtitle style editor, drilled into from the Video Subtitles page: a live preview above
* three panels — the layout, and one for each of the two lines. Far too tall for a row.
* three panels — the layout, and one for each of the two lines. Far too tall for a row. Anything
* the panels cannot express is a page further in, behind the custom CSS row at the bottom.
*/
export function SubtitlesStylePage() {
return (
@@ -26,6 +28,11 @@ export function SubtitlesStylePage() {
<MainSubtitlesStyle />
<TranslationSubtitlesStyle />
</div>
<ConfigNavItem
to="/video-subtitles/style/custom-css"
title={i18n.t("options.videoSubtitles.style.customCSS.title")}
description={i18n.t("options.videoSubtitles.style.customCSS.description")}
/>
</ConfigDetailSection>
</PageLayout>
)
@@ -0,0 +1,96 @@
import type { ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import themeCSS from "@/assets/styles/theme.css?inline"
import { SUBTITLES_THEME } from "@/utils/constants/subtitles"
import { ensureSubtitlesCustomCSS } from "@/utils/host/translate/ui/style-injector"
import { ShadowHostBuilder } from "@/utils/react-shadow-host/shadow-host-builder"
import { cn } from "@/utils/styles/utils"
import { applyTheme } from "@/utils/theme"
interface ShadowPreviewFrameProps {
/** The user's CSS, verbatim. Nothing rewrites it — the shadow boundary is what contains it. */
customCSS?: string
children: ReactNode
className?: string
}
/**
* Renders the subtitle preview inside a shadow root, the way the real overlay runs.
*
* The preview shows CSS the user is still typing, so a rule broad enough to match the settings page
* around it — `* { filter: blur(10px) }`, or a stray `}` followed by `body { display: none }` — must
* not be able to reach it. Rewriting every selector to sit under a scope class was the obvious way
* to arrange that, and it leaked in three directions at once: at-rules with no selector (`@font-face`,
* `@property`) passed through untouched, `@keyframes` names stayed global and could hijack the
* settings page's own animations, and every rewritten selector gained a class's worth of specificity
* that production never adds — so the preview could show a rule winning that loses on the video.
*
* A shadow root has none of those seams because the containment is the browser's, not ours. It also
* makes the preview structurally the same thing as production: the same `ShadowHostBuilder`, the same
* `theme.css`, the same `ensureSubtitlesCustomCSS` injection, and the user's CSS byte-for-byte.
*
* What a shadow root does NOT contain is layout: `position: fixed` still resolves against the
* viewport, and `height: 100000px` still grows the page and pushes the editor below the fold. That
* is what the containment on the light-DOM wrapper is for, and it has to live out here — a rule
* inside could otherwise turn it off.
*/
export function ShadowPreviewFrame({ customCSS, children, className }: ShadowPreviewFrameProps) {
const hostRef = useRef<HTMLDivElement>(null)
const shadowRootRef = useRef<ShadowRoot | null>(null)
const [container, setContainer] = useState<HTMLElement | null>(null)
useEffect(() => {
const host = hostRef.current
if (!host) return undefined
// StrictMode runs this effect twice against the same host, and `attachShadow` throws on a host
// that already has one — reuse it and clear what the previous pass left behind.
const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" })
shadowRoot.replaceChildren()
const builder = new ShadowHostBuilder(shadowRoot, {
position: "block",
cssContent: [themeCSS],
inheritStyles: false,
})
const wrapper = builder.build()
// The overlay pins its own theme rather than following the page it sits on; the preview has to
// pin the same one or it shows the subtitle box against the wrong tokens.
applyTheme(wrapper, SUBTITLES_THEME)
shadowRootRef.current = shadowRoot
setContainer(wrapper)
return () => {
builder.cleanup()
shadowRoot.replaceChildren()
shadowRootRef.current = null
setContainer(null)
}
}, [])
useEffect(() => {
const shadowRoot = shadowRootRef.current
if (!shadowRoot) return
// Empty string rather than an early return: clearing the editor has to take the old sheet back
// off, not leave the last one adopted.
void ensureSubtitlesCustomCSS(shadowRoot, customCSS ?? "")
}, [customCSS, container])
return (
<div
className={cn(
// `contain: layout` makes this the containing block for any `position: fixed` inside,
// including across the shadow boundary; `paint` plus the max height and overflow keep a
// runaway box from growing the settings page under it. Verified in Chrome: without them a
// 100000px-tall rule pushes the CSS editor 100k pixels down the page.
"max-h-[420px] overflow-hidden [contain:layout_paint]",
className,
)}
>
<div ref={hostRef} />
{container && createPortal(children, container)}
</div>
)
}
@@ -6,9 +6,19 @@ import {
TranslationSubtitle,
} from "@/entrypoints/subtitles.content/ui/subtitle-lines"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_BOX_CLASS, SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { ShadowPreviewFrame } from "./shadow-preview-frame"
export function SubtitlesPreview() {
interface SubtitlesPreviewProps {
/**
* Custom CSS to show the effect of before it is saved. Omitted, the preview falls back to what
* the config holds, so the style page shows the saved CSS rather than pretending there is none.
*/
previewCSS?: string
}
export function SubtitlesPreview({ previewCSS }: SubtitlesPreviewProps) {
const { style } = useAtomValue(configFieldsAtomMap.videoSubtitles)
const { displayMode, translationPosition, container } = style
@@ -26,26 +36,41 @@ export function SubtitlesPreview() {
return (
<GradientBackground>
<div className="relative flex h-fit min-h-32 w-fit min-w-full items-center justify-center overflow-hidden rounded-lg p-4">
<ShadowPreviewFrame
customCSS={previewCSS ?? style.customCSS ?? ""}
className="relative h-fit w-fit min-w-full rounded-lg"
>
{/* The same class hooks the real overlay carries, in the same nesting, so a selector
written against the documented names matches here exactly as it will on the video. */}
<div
className="flex max-w-[90%] flex-col gap-2 rounded px-3 py-2 text-center text-white"
style={containerStyle}
className={cn(
SUBTITLES_VIEW_CLASS,
"flex min-h-32 w-full items-center justify-center p-4",
)}
>
<Activity mode={showMain ? "visible" : "hidden"}>
<MainSubtitle
content={sampleOriginal}
className={cn("text-sm", translationAbove ? "order-2" : "order-1")}
/>
</Activity>
<div
className={cn(
SUBTITLES_BOX_CLASS,
"flex max-w-[90%] flex-col gap-2 rounded px-3 py-2 text-center text-white",
)}
style={containerStyle}
>
<Activity mode={showMain ? "visible" : "hidden"}>
<MainSubtitle
content={sampleOriginal}
className={cn("text-sm", translationAbove ? "order-2" : "order-1")}
/>
</Activity>
<Activity mode={showTranslation ? "visible" : "hidden"}>
<TranslationSubtitle
content={sampleTranslation}
className={cn("text-sm", translationAbove ? "order-1" : "order-2")}
/>
</Activity>
<Activity mode={showTranslation ? "visible" : "hidden"}>
<TranslationSubtitle
content={sampleTranslation}
className={cn("text-sm", translationAbove ? "order-1" : "order-2")}
/>
</Activity>
</div>
</div>
</div>
</ShadowPreviewFrame>
</GradientBackground>
)
}
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react"
import type { SubtitleTextStyle } from "@/types/config/subtitles"
import { useAtomValue } from "jotai"
import { useEffect, useRef } from "react"
@@ -14,13 +15,19 @@ interface SubtitleLineProps {
className?: string
}
function getTextStyles(textStyle: SubtitleTextStyle) {
/**
* The picked style, as custom properties rather than the properties themselves. `subtitle-lines.css`
* turns them into real declarations; going through a variable is what lets custom CSS override a
* colour or size without `!important`, since an inline `color` would outrank every stylesheet rule.
*/
function getTextStyleVars(textStyle: SubtitleTextStyle): CSSProperties {
return {
fontFamily: SUBTITLE_FONT_FAMILIES[textStyle.fontFamily] || SUBTITLE_FONT_FAMILIES.system,
fontSize: `${textStyle.fontScale / 100}em`,
color: textStyle.color,
fontWeight: textStyle.fontWeight,
}
"--rf-subtitle-font-family":
SUBTITLE_FONT_FAMILIES[textStyle.fontFamily] || SUBTITLE_FONT_FAMILIES.system,
"--rf-subtitle-font-size": `${textStyle.fontScale / 100}em`,
"--rf-subtitle-color": textStyle.color,
"--rf-subtitle-font-weight": String(textStyle.fontWeight),
} as CSSProperties
}
export function MainSubtitle({ content, className }: SubtitleLineProps) {
@@ -31,7 +38,7 @@ export function MainSubtitle({ content, className }: SubtitleLineProps) {
return (
<div
className={cn("subtitles-main text-xl leading-tight", className)}
style={getTextStyles(style.main)}
style={getTextStyleVars(style.main)}
>
{text}
</div>
@@ -45,7 +52,7 @@ export function TranslationSubtitle({ content, className }: SubtitleLineProps) {
const pending = content === undefined && isTranslationPending(subtitle)
const text = content ?? subtitle?.translation ?? ""
const { dir, lang } = getLanguageDirectionAndLang(language.targetCode)
const textStyles = getTextStyles(style.translation)
const textStyleVars = getTextStyleVars(style.translation)
const lastFrameRef = useRef<{ start?: number; pending: boolean }>({
start: undefined,
pending: false,
@@ -67,11 +74,9 @@ export function TranslationSubtitle({ content, className }: SubtitleLineProps) {
"subtitles-translation flex min-h-[1.25em] items-center justify-center leading-tight",
className,
)}
style={{
fontFamily: textStyles.fontFamily,
fontSize: textStyles.fontSize,
color: textStyles.color,
}}
// The pending label deliberately does not take the picked weight: it is a placeholder, not
// the translation, and inherits whatever the box uses.
style={{ ...textStyleVars, "--rf-subtitle-font-weight": undefined } as CSSProperties}
dir={dir}
lang={lang}
data-pending="true"
@@ -89,7 +94,7 @@ export function TranslationSubtitle({ content, className }: SubtitleLineProps) {
justResolved && "animate-subtitle-fade-in",
className,
)}
style={textStyles}
style={textStyleVars}
dir={dir}
lang={lang}
>
@@ -7,6 +7,7 @@ import { StateMessage } from "./state-message"
import { SubtitlesSettingsPanel } from "./subtitles-settings-panel"
import { SubtitlesUIContext } from "./subtitles-ui-context"
import { SubtitlesView } from "./subtitles-view"
import { useSubtitlesCustomCSS } from "./use-subtitles-custom-css"
export function SubtitlesContainer() {
const { stateData, isVisible } = useAtomValue(subtitlesDisplayAtom)
@@ -18,6 +19,8 @@ export function SubtitlesContainer() {
// reach its trigger and survives the player going fullscreen.
const shadowWrapper = use(ShadowWrapperContext)
useSubtitlesCustomCSS()
return (
<div className="pointer-events-none absolute inset-0 overflow-visible">
<div className="absolute inset-0 z-10 overflow-visible">
@@ -2,7 +2,7 @@ import { IconGripHorizontal } from "@tabler/icons-react"
import { useAtomValue } from "jotai"
import { Activity } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { SUBTITLES_BOX_CLASS, SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { displaySubtitleAtom } from "../atoms"
import { MainSubtitle, TranslationSubtitle } from "./subtitle-lines"
@@ -33,7 +33,7 @@ function SubtitlesContent() {
className={`${SUBTITLES_VIEW_CLASS} pointer-events-none flex w-full flex-col items-center justify-end pb-3`}
>
<div
className="pointer-events-auto mx-auto flex w-fit max-w-[90%] cursor-text flex-col gap-2 rounded px-2 py-1.5 text-center text-white select-text"
className={`${SUBTITLES_BOX_CLASS} pointer-events-auto mx-auto flex w-fit max-w-[90%] cursor-text flex-col gap-2 rounded px-2 py-1.5 text-center text-white select-text`}
style={containerStyle}
>
<Activity mode={showMain ? "visible" : "hidden"}>
@@ -0,0 +1,26 @@
import { useAtomValue } from "jotai"
import { use, useEffect } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { ensureSubtitlesCustomCSS } from "@/utils/host/translate/ui/style-injector"
import { ShadowWrapperContext } from "@/utils/react-shadow-host/create-shadow-host"
/**
* Keep the subtitles shadow root's custom stylesheet in step with the config.
*
* The options-page preview scopes the same CSS to its preview box; here the shadow root is already
* the boundary, so the user's rules go in untouched.
*/
export function useSubtitlesCustomCSS(): void {
const { style } = useAtomValue(configFieldsAtomMap.videoSubtitles)
const shadowWrapper = use(ShadowWrapperContext)
const customCSS = style.customCSS
useEffect(() => {
const root = shadowWrapper?.getRootNode()
if (!(root instanceof ShadowRoot)) return
// Clearing the CSS has to write an empty sheet rather than skip the call, or the last saved
// rules stay adopted and the subtitles keep a style the user just turned off.
void ensureSubtitlesCustomCSS(root, customCSS ?? "")
}, [customCSS, shadowWrapper])
}
+31
View File
@@ -1103,6 +1103,37 @@ options:
fontWeight: Font weight
backgroundOpacity: Background opacity
reset: Reset to default
customCSS:
title: Custom CSS
description: Extra CSS for the subtitle lines, on top of the fonts and colors above
presetTemplate: Preset template
presetTemplateDescription: Apply a ready-made template — such as blurring the translation to cover it while you train your listening
presetPlaceholder: Choose a template
presets:
blurTranslation: Blur translation
dashedTranslation: Dashed translation
dimOriginal: Dim original
editor:
placeholder: |
/* Example: blur the translation, and reveal it on hover */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* Selectors you can use: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: How to configure?
saveButton: Save
savedButton: Saved
validation:
validating: Validating...
valid: CSS is valid
syntaxError: CSS has syntax errors
tooLong: CSS is too long
saved: All changes saved
customPrompts:
title: Custom subtitle prompts
description: Prompt templates for subtitle translation, kept apart from the webpage translation ones
+31
View File
@@ -1103,6 +1103,37 @@ options:
fontWeight: Grosor de fuente
backgroundOpacity: Opacidad del fondo
reset: Restablecer a predeterminado
customCSS:
title: CSS personalizado
description: CSS adicional para las líneas de subtítulos, además de las fuentes y colores de arriba
presetTemplate: Plantilla predefinida
presetTemplateDescription: Aplica una plantilla lista para usar, como difuminar la traducción para taparla mientras practicas comprensión auditiva
presetPlaceholder: Elegir una plantilla
presets:
blurTranslation: Difuminar traducción
dashedTranslation: Traducción subrayada a trazos
dimOriginal: Atenuar original
editor:
placeholder: |
/* Ejemplo: difumina la traducción y muéstrala al pasar el cursor */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* Selectores disponibles: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: ¿Cómo configurar?
saveButton: Guardar
savedButton: Guardado
validation:
validating: Validando...
valid: CSS válido
syntaxError: CSS tiene errores de sintaxis
tooLong: CSS es demasiado largo
saved: Todos los cambios guardados
customPrompts:
title: Prompts personalizados de subtítulos
description: Plantillas de prompts solo para la traducción de subtítulos, separadas de las de traducción de páginas web
+31
View File
@@ -987,6 +987,37 @@ options:
fontWeight: フォントウェイト
backgroundOpacity: 背景の透明度
reset: デフォルトにリセット
customCSS:
title: カスタム CSS
description: 上のフォントや色に加えて、字幕行に CSS を追加します
presetTemplate: プリセットテンプレート
presetTemplateDescription: プリセットテンプレートをすぐに適用します。たとえばリスニング練習の間だけ訳文をぼかして隠す効果など
presetPlaceholder: テンプレートを選択
presets:
blurTranslation: 訳文をぼかす
dashedTranslation: 訳文に破線
dimOriginal: 原文を弱める
editor:
placeholder: |
/* 例:訳文をぼかし、カーソルを重ねたときに表示する */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* 使用できるセレクター:.read-frog-subtitles-box、.subtitles-main、.subtitles-translation */
docsLink: 設定方法は?
saveButton: 保存
savedButton: 保存済み
validation:
validating: 検証中...
valid: CSSは有効です
syntaxError: CSSに構文エラーがあります
tooLong: CSSが長すぎます
saved: すべての変更が保存されました
customPrompts:
title: カスタム字幕プロンプト
description: 字幕翻訳専用のプロンプトテンプレート。ウェブページ翻訳のプロンプトとは別に管理します
+31
View File
@@ -987,6 +987,37 @@ options:
fontWeight: 글꼴 굵기
backgroundOpacity: 배경 투명도
reset: 기본값으로 재설정
customCSS:
title: 사용자 지정 CSS
description: 위의 글꼴과 색상에 더해 자막 줄에 CSS를 추가합니다
presetTemplate: 프리셋 템플릿
presetTemplateDescription: 프리셋 템플릿을 바로 적용합니다. 예를 들어 듣기 연습 중에는 번역문을 흐리게 가리는 효과
presetPlaceholder: 템플릿 선택
presets:
blurTranslation: 번역문 흐리게
dashedTranslation: 번역문 점선
dimOriginal: 원문 연하게
editor:
placeholder: |
/* 예: 번역문을 흐리게 두고 마우스를 올리면 보이게 합니다 */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* 사용할 수 있는 선택자: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: 구성 방법은?
saveButton: 저장
savedButton: 저장됨
validation:
validating: 검증 중...
valid: CSS가 유효합니다
syntaxError: CSS에 구문 오류가 있습니다
tooLong: CSS가 너무 깁니다
saved: 모든 변경사항이 저장되었습니다
customPrompts:
title: 사용자 정의 자막 프롬프트
description: 자막 번역 전용 프롬프트 템플릿으로, 웹페이지 번역 프롬프트와 별도로 관리합니다
+31
View File
@@ -987,6 +987,37 @@ options:
fontWeight: Толщина шрифта
backgroundOpacity: Прозрачность фона
reset: Сбросить по умолчанию
customCSS:
title: Пользовательский CSS
description: Дополнительный CSS для строк субтитров, поверх шрифтов и цветов выше
presetTemplate: Готовый шаблон
presetTemplateDescription: Быстро примените готовый шаблон — например, размытие перевода, чтобы скрыть его во время тренировки аудирования
presetPlaceholder: Выбрать шаблон
presets:
blurTranslation: Размыть перевод
dashedTranslation: Пунктир под переводом
dimOriginal: Приглушить оригинал
editor:
placeholder: |
/* Пример: размыть перевод и показывать его при наведении */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* Доступные селекторы: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: Как настроить?
saveButton: Сохранить
savedButton: Сохранено
validation:
validating: Проверка...
valid: CSS корректен
syntaxError: CSS содержит синтаксические ошибки
tooLong: CSS слишком длинный
saved: Все изменения сохранены
customPrompts:
title: Пользовательские промпты субтитров
description: Шаблоны промптов только для перевода субтитров, отдельно от промптов перевода веб-страниц
+31
View File
@@ -987,6 +987,37 @@ options:
fontWeight: Yazı kalınlığı
backgroundOpacity: Arka plan şeffaflığı
reset: Varsayılana sıfırla
customCSS:
title: Özel CSS
description: Yukarıdaki yazı tipleri ve renklerin üzerine, altyazı satırları için ek CSS
presetTemplate: Hazır şablon
presetTemplateDescription: Hazır bir şablonu hemen uygulayın — örneğin dinleme çalışırken çeviriyi bulanıklaştırıp gizleyen efekt
presetPlaceholder: Şablon seçin
presets:
blurTranslation: Çeviriyi bulanıklaştır
dashedTranslation: Çeviriye kesik çizgi
dimOriginal: Orijinali soluklaştır
editor:
placeholder: |
/* Örnek: çeviriyi bulanıklaştır, üzerine gelince göster */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* Kullanabileceğiniz seçiciler: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: Nasıl yapılandırılır?
saveButton: Kaydet
savedButton: Kaydedildi
validation:
validating: Doğrulanıyor...
valid: CSS geçerli
syntaxError: CSS sözdizimi hataları içeriyor
tooLong: CSS çok uzun
saved: Tüm değişiklikler kaydedildi
customPrompts:
title: Özel altyazı istemleri
description: Yalnızca altyazı çevirisi için istem şablonları; web sayfası çeviri istemlerinden ayrı tutulur
+31
View File
@@ -987,6 +987,37 @@ options:
fontWeight: Độ đậm chữ
backgroundOpacity: Độ trong suốt nền
reset: Đặt lại về mặc định
customCSS:
title: CSS tùy chỉnh
description: CSS bổ sung cho các dòng phụ đề, ngoài phông chữ và màu sắc ở trên
presetTemplate: Mẫu dựng sẵn
presetTemplateDescription: Áp dụng nhanh một mẫu dựng sẵn — ví dụ làm mờ bản dịch để che đi khi luyện nghe
presetPlaceholder: Chọn mẫu
presets:
blurTranslation: Làm mờ bản dịch
dashedTranslation: Gạch nét đứt bản dịch
dimOriginal: Làm nhạt bản gốc
editor:
placeholder: |
/* Ví dụ: làm mờ bản dịch, di chuột vào để xem */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* Các bộ chọn có thể dùng: .read-frog-subtitles-box, .subtitles-main, .subtitles-translation */
docsLink: Cấu hình như thế nào?
saveButton: Lưu
savedButton: Đã lưu
validation:
validating: Đang xác thực...
valid: CSS hợp lệ
syntaxError: CSS có lỗi cú pháp
tooLong: CSS quá dài
saved: Tất cả thay đổi đã được lưu
customPrompts:
title: Lời nhắc phụ đề tùy chỉnh
description: Mẫu lời nhắc chỉ dùng cho dịch phụ đề, tách biệt với lời nhắc dịch trang web
+31
View File
@@ -1103,6 +1103,37 @@ options:
fontWeight: 字体粗细
backgroundOpacity: 背景透明度
reset: 重置为默认
customCSS:
title: 自定义 CSS
description: 在上面的字体和颜色之上,为字幕行追加 CSS
presetTemplate: 预设模板
presetTemplateDescription: 快速应用预设模板,比如暂时遮住译文用于听力训练学习的模糊效果
presetPlaceholder: 选择模板
presets:
blurTranslation: 模糊译文
dashedTranslation: 虚线译文
dimOriginal: 弱化原文
editor:
placeholder: |
/* 示例:先把译文模糊,鼠标移上去再显示 */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* 可用的选择器:.read-frog-subtitles-box、.subtitles-main、.subtitles-translation */
docsLink: 如何配置?
saveButton: 保存
savedButton: 已保存
validation:
validating: 验证中...
valid: CSS 有效
syntaxError: CSS 存在语法错误
tooLong: CSS 过长
saved: 所有更改已保存
customPrompts:
title: 自定义字幕提示词
description: 字幕翻译专用的提示词模板,与网页翻译提示词分开
+31
View File
@@ -1103,6 +1103,37 @@ options:
fontWeight: 字重
backgroundOpacity: 背景透明度
reset: 重設為預設值
customCSS:
title: 自訂 CSS
description: 在上面的字型和顏色之上,為字幕行追加 CSS
presetTemplate: 預設模板
presetTemplateDescription: 快速套用預設模板,例如暫時遮住譯文用於聽力訓練學習的模糊效果
presetPlaceholder: 選擇模板
presets:
blurTranslation: 模糊譯文
dashedTranslation: 虛線譯文
dimOriginal: 弱化原文
editor:
placeholder: |
/* 範例:先把譯文模糊,滑鼠移上去再顯示 */
.subtitles-translation {
filter: blur(6px);
}
.subtitles-translation:hover {
filter: none;
}
/* 可用的選擇器:.read-frog-subtitles-box、.subtitles-main、.subtitles-translation */
docsLink: 如何設定?
saveButton: 儲存
savedButton: 已儲存
validation:
validating: 驗證中…
valid: CSS 有效
syntaxError: CSS 存在語法錯誤
tooLong: CSS 過長
saved: 所有變更已儲存
customPrompts:
title: 自訂字幕提示詞
description: 字幕翻譯專用的提示詞範本,與網頁翻譯提示詞分開
+3
View File
@@ -11,6 +11,7 @@ import {
import {
batchQueueConfigSchema,
createCustomPromptsConfigSchema,
MAX_CUSTOM_CSS_LENGTH,
pageTranslationShortcutSchema,
requestQueueConfigSchema,
} from "./translate"
@@ -40,6 +41,8 @@ export const subtitlesStyleSchema = z.object({
main: subtitleTextStyleSchema,
translation: subtitleTextStyleSchema,
container: subtitleContainerStyleSchema,
/** Extra CSS for the subtitle lines, on top of the picked fonts and colours. `null` is off. */
customCSS: z.string().max(MAX_CUSTOM_CSS_LENGTH, "Custom CSS cannot exceed 8KB").nullable(),
})
export const subtitlePositionSchema = z.object({
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest"
import { migrate } from "../../migration-scripts/v099-to-v100"
/** A stored v099 subtitle style: everything the editor sets, and no `customCSS`. Typed `any` like
* the migration it feeds — this is a stored shape, not the current schema. */
function configWithSubtitleStyle(): any {
return {
uiLanguage: "zh-CN",
videoSubtitles: {
enabled: true,
providerId: "microsoft-translate-default",
style: {
displayMode: "bilingual",
translationPosition: "above",
main: { fontFamily: "system", fontScale: 100, color: "#FFFFFF", fontWeight: 400 },
translation: { fontFamily: "roboto", fontScale: 120, color: "#FFDD00", fontWeight: 500 },
container: { backgroundOpacity: 70 },
},
position: { percent: 10, anchor: "bottom" },
},
}
}
describe("v099 to v100 migration", () => {
it("adds customCSS as null", () => {
expect(migrate(configWithSubtitleStyle()).videoSubtitles.style.customCSS).toBeNull()
})
it("leaves every other style field untouched", () => {
const before = configWithSubtitleStyle()
const migrated = migrate(before)
expect(migrated.videoSubtitles.style).toEqual({
...before.videoSubtitles.style,
customCSS: null,
})
expect(migrated.videoSubtitles.position).toEqual(before.videoSubtitles.position)
expect(migrated.uiLanguage).toBe("zh-CN")
})
it("is idempotent, and keeps CSS a re-run would otherwise clear", () => {
const withCSS = configWithSubtitleStyle()
withCSS.videoSubtitles.style.customCSS = ".subtitles-main{opacity:0.6}"
const migrated = migrate(withCSS)
expect(migrated).toBe(withCSS)
expect(migrated.videoSubtitles.style.customCSS).toBe(".subtitles-main{opacity:0.6}")
})
it("returns configs it cannot place the field in untouched", () => {
for (const config of [null, undefined, "nope", {}, { videoSubtitles: {} }]) {
expect(migrate(config)).toBe(config)
}
})
})
@@ -0,0 +1,50 @@
/**
* Migration script from v099 to v100.
*
* Gives `videoSubtitles.style` a `customCSS` slot, holding extra CSS for the two subtitle lines on
* top of the fonts, sizes and colours the style editor already sets. `null` means no custom CSS,
* which is what every existing profile gets here.
*
* The field is nested rather than top-level, so a schema default cannot stand in for this step:
* `migrateConfig` only reaches for defaults on whole sections, and a stored v099 config would
* otherwise fail `configSchema` outright and be replaced by DEFAULT_CONFIG — losing the user's
* providers along with their subtitle styling.
*
* Idempotent: a config that already carries the key is returned by identity, as is one whose
* `videoSubtitles.style` is missing or not an object (the schema parse that follows will report
* that far better than a migration guessing at a repair).
*
* IMPORTANT: This is a frozen snapshot. All values and helpers are deliberately inline and it
* imports nothing from the evolving application code.
*/
function isObject(value: any): value is Record<string, any> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
export function migrate(oldConfig: any): any {
if (!isObject(oldConfig)) {
return oldConfig
}
const videoSubtitles = oldConfig.videoSubtitles
if (!isObject(videoSubtitles)) {
return oldConfig
}
const style = videoSubtitles.style
if (!isObject(style) || "customCSS" in style) {
return oldConfig
}
return {
...oldConfig,
videoSubtitles: {
...videoSubtitles,
style: {
...style,
customCSS: null,
},
},
}
}
+2 -1
View File
@@ -50,7 +50,7 @@ export const GOOGLE_DRIVE_TOKEN_STORAGE_KEY = "__googleDriveToken"
export const THEME_STORAGE_KEY = "theme"
export const DEFAULT_DETECTED_CODE = "eng" as const
export const CONFIG_SCHEMA_VERSION = 99
export const CONFIG_SCHEMA_VERSION = 100
export const DEFAULT_FLOATING_BUTTON_POSITION = 0.66
export const DEFAULT_FLOATING_BUTTON_SIDE: FloatingButtonSide = "right"
@@ -206,6 +206,7 @@ export const DEFAULT_CONFIG: Config = {
container: {
backgroundOpacity: DEFAULT_BACKGROUND_OPACITY,
},
customCSS: null,
},
aiSegmentation: false,
requestQueueConfig: {
+36
View File
@@ -23,6 +23,9 @@ export const HIDE_NATIVE_CAPTIONS_STYLE_ID = "read-frog-hide-native-captions"
// Class names
export const SUBTITLES_VIEW_CLASS = "read-frog-subtitles-view"
// The box the two subtitle lines sit in. Everything else about it is Tailwind utilities, but
// custom CSS needs a name it can hold on to, so this one is part of the public contract.
export const SUBTITLES_BOX_CLASS = "read-frog-subtitles-box"
export const STATE_MESSAGE_CLASS = "read-frog-subtitles-state-message"
export const TRANSLATE_BUTTON_CLASS = "read-frog-subtitles-translate-button"
@@ -82,6 +85,39 @@ export const SUBTITLE_FONT_FAMILIES = {
"noto-serif": '"Noto Serif", "Noto Serif SC", "Noto Serif JP", "Noto Serif KR", serif',
}
// Custom CSS
// The three class hooks above (view, box, and the two line classes) are what custom CSS targets.
// Presets are appended into the editor as plain text, so each one is a self-contained block that
// only touches properties none of the sliders own — that way stacking two of them never conflicts
// with a font size or colour the user already picked.
export const SUBTITLE_CSS_PRESET_IDS = [
"blurTranslation",
"dashedTranslation",
"dimOriginal",
] as const
export type SubtitleCssPresetId = (typeof SUBTITLE_CSS_PRESET_IDS)[number]
export const SUBTITLE_CSS_PRESETS: Record<SubtitleCssPresetId, string> = {
// Hover the line itself rather than the box: the outer view is pointer-events: none, and the
// box is wider than the text, so revealing on the text is both simpler and more deliberate.
blurTranslation: `.subtitles-translation {
filter: blur(6px);
transition: filter 0.15s ease;
}
.subtitles-translation:hover {
filter: none;
}`,
dashedTranslation: `.subtitles-translation {
text-decoration: underline dashed;
text-decoration-thickness: 1px;
text-underline-offset: 0.25em;
}`,
dimOriginal: `.subtitles-main {
opacity: 0.6;
}`,
}
// Subtitles source
export const SUBTITLES_SOURCE = { NATIVE: "native", AI: "ai" } as const
export type SubtitlesSource = (typeof SUBTITLES_SOURCE)[keyof typeof SUBTITLES_SOURCE]
@@ -2,8 +2,8 @@ import type { TranslationNodeStyleConfig } from "@/types/config/translate"
import { camelCase } from "case-anything"
import { translationNodeStylePresetSchema } from "@/types/config/translate"
import { CUSTOM_TRANSLATION_NODE_ATTRIBUTE } from "@/utils/constants/translation-node-style"
import { getContainingShadowRoot } from "../../dom/node"
import { ensureCustomCSS, ensurePresetStyles } from "./style-injector"
import { getContainingShadowRoot, getOwnerDocument } from "../../dom/node"
import { clearCustomCSS, ensureCustomCSS, ensurePresetStyles } from "./style-injector"
const customTranslationNodeAttribute = camelCase(CUSTOM_TRANSLATION_NODE_ATTRIBUTE)
@@ -13,7 +13,10 @@ export async function decorateTranslationNode(
): Promise<void> {
if (translationNodeStylePresetSchema.safeParse(styleConfig.preset).error) return
const root = getContainingShadowRoot(translatedNode) ?? document
// The node's own document rather than the ambient one: on a real page the two are the same, but
// the options page previews this inside an iframe, and the styling has to land in the frame that
// holds the node instead of on the settings page around it.
const root = getContainingShadowRoot(translatedNode) ?? getOwnerDocument(translatedNode)
if (styleConfig.isCustom && styleConfig.customCSS) {
translatedNode.dataset[customTranslationNodeAttribute] = "custom"
@@ -23,4 +26,8 @@ export async function decorateTranslationNode(
translatedNode.dataset[customTranslationNodeAttribute] = styleConfig.preset
ensurePresetStyles(root)
// The attribute alone is not enough to go back to a preset: custom CSS from an earlier call is
// still adopted on this root, and anything the preset does not set — `border` sets no colour, for
// one — is still wearing it.
await clearCustomCSS(root)
}
+78 -7
View File
@@ -7,6 +7,22 @@ type StyleRoot = Document | ShadowRoot
// ============ Utilities ============
/**
* Whether the root is a Document, tested in a way that survives crossing realms.
*
* An iframe's Document fails an `instanceof` check run from the parent realm, and the options page
* previews translation styling inside a same-origin frame — the only container that is also a
* Document, which is what production injects into. `nodeType` is stable across realms and agrees
* with `instanceof` for both production roots: Document is 9, ShadowRoot is 11.
*/
function isDocumentRoot(root: StyleRoot): root is Document {
return root.nodeType === Node.DOCUMENT_NODE
}
function getRootDocument(root: StyleRoot): Document {
return isDocumentRoot(root) ? root : (root.ownerDocument ?? document)
}
// Cache the probe result per root so we only touch adoptedStyleSheets once.
const constructableStyleSheetSupportMap = new WeakMap<StyleRoot, boolean>()
@@ -24,6 +40,15 @@ function supportsConstructableStyleSheets(
return false
}
// A constructed stylesheet belongs to the realm that built it, and assigning one to another
// document throws NotAllowedError. Nothing here can build a sheet in a foreign realm, so a root
// from one takes the <style> path instead — the same path Firefox already falls back to. Checked
// ahead of the probe below so the expected case does not surface as a warning.
if (getRootDocument(root) !== document) {
constructableStyleSheetSupportMap.set(root, false)
return false
}
if (!("adoptedStyleSheets" in root) || root.adoptedStyleSheets === undefined) {
constructableStyleSheetSupportMap.set(root, false)
return false
@@ -64,10 +89,10 @@ function supportsConstructableStyleSheets(
}
function injectStyleElement(root: StyleRoot, id: string, cssText: string): void {
const container = root instanceof Document ? root.head : root
const container = isDocumentRoot(root) ? root.head : root
let styleElement = root.querySelector<HTMLStyleElement>(`#${id}`)
if (!styleElement) {
styleElement = document.createElement("style")
styleElement = getRootDocument(root).createElement("style")
styleElement.id = id
container.appendChild(styleElement)
}
@@ -88,11 +113,11 @@ let documentPresetStyleSheet: CSSStyleSheet | null = null
let shadowPresetStyleSheet: CSSStyleSheet | null = null
function getPresetCSS(root: StyleRoot): string {
return root instanceof Document ? DOCUMENT_PRESET_CSS : SHADOW_PRESET_CSS
return isDocumentRoot(root) ? DOCUMENT_PRESET_CSS : SHADOW_PRESET_CSS
}
function getPresetStyleSheet(root: StyleRoot): CSSStyleSheet {
if (root instanceof Document) {
if (isDocumentRoot(root)) {
if (!documentPresetStyleSheet) {
documentPresetStyleSheet = new CSSStyleSheet()
documentPresetStyleSheet.replaceSync(DOCUMENT_PRESET_CSS)
@@ -159,18 +184,64 @@ export function removeSiteRuleCSS(root: StyleRoot): void {
root.querySelector(`#${SITE_RULE_STYLE_ID}`)?.remove()
}
// ============ Subtitles Custom CSS Injection ============
const SUBTITLES_CUSTOM_STYLE_ID = "read-frog-subtitles-custom-styles"
const subtitlesCustomCSSMap = new WeakMap<StyleRoot, CSSStyleSheet>()
/**
* Inject the user's subtitle CSS into the subtitles shadow root.
*
* Deliberately not `ensureCustomCSS` below: that one pulls in the translation preset styles first,
* which redefine the `--rf-*` theme tokens the subtitles root already gets from `theme.css` — the
* subtitle settings panel lives in that same root and would be recoloured by the side effect.
*
* Appending the sheet last is what lets custom CSS win over `subtitle-lines.css`, which is why the
* picked font and colour reach the line as custom properties rather than as inline styles.
*/
export async function ensureSubtitlesCustomCSS(root: StyleRoot, cssText: string): Promise<void> {
if (supportsConstructableStyleSheets(root)) {
let sheet = subtitlesCustomCSSMap.get(root)
if (!sheet) {
sheet = new CSSStyleSheet()
// Set in map first to prevent race condition with concurrent calls
subtitlesCustomCSSMap.set(root, sheet)
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]
}
await sheet.replace(cssText)
} else {
injectStyleElement(root, SUBTITLES_CUSTOM_STYLE_ID, cssText)
}
}
// ============ Custom CSS Injection ============
const CUSTOM_STYLE_ID = "read-frog-custom-styles"
const customCSSMap = new WeakMap<StyleRoot, CSSStyleSheet>()
let documentCachedCSS: string | null = null
/**
* Withdraw custom CSS a previous `ensureCustomCSS` put on this root.
*
* Switching back to a preset, or emptying the editor, leaves the old sheet adopted otherwise — the
* rules go on applying until the page is reloaded, and the options preview never reloads, so there
* it reads as deleting the CSS having done nothing at all.
*
* Guarded so a root that never had custom CSS does not acquire an empty sheet just for being
* styled by a preset.
*/
export async function clearCustomCSS(root: StyleRoot): Promise<void> {
if (!customCSSMap.has(root) && !root.querySelector(`#${CUSTOM_STYLE_ID}`)) return
await ensureCustomCSS(root, "")
}
/** Inject custom CSS into the given root */
export async function ensureCustomCSS(root: StyleRoot, cssText: string): Promise<void> {
// Ensure preset styles are injected first (provides CSS variables)
ensurePresetStyles(root)
// Document-level cache optimization
if (root instanceof Document && documentCachedCSS === cssText) {
if (root === document && documentCachedCSS === cssText) {
return
}
@@ -184,10 +255,10 @@ export async function ensureCustomCSS(root: StyleRoot, cssText: string): Promise
}
await sheet.replace(cssText)
} else {
injectStyleElement(root, "read-frog-custom-styles", cssText)
injectStyleElement(root, CUSTOM_STYLE_ID, cssText)
}
if (root instanceof Document) {
if (root === document) {
documentCachedCSS = cssText
}
}