feat(reader): Webtoon Mode seamless continuous scroll for image books (#3647) (#4662)

* feat(reader): make fixed-layout scroll gap configurable (foliate-js bump) (#3647)

* feat(reader): add webtoonMode view setting + scroll-gap helper (#3647)

* feat(reader): Webtoon Mode toggle in the fixed-layout view menu (#3647)

* feat(reader): apply Webtoon Mode gap on fixed-layout book open (#3647)

* fix(reader): clear Webtoon Mode + reset gap when Shift+J leaves scrolled (#3647)

* chore(i18n): translate Webtoon Mode string across locales (#3647)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(deps): bump foliate-js to merged readest/foliate-js#30 (#3647)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 18:37:37 +08:00
committed by GitHub
parent 590c44f977
commit 6caa376f82
42 changed files with 137 additions and 34 deletions
@@ -1906,5 +1906,6 @@
"biometrics": "البيومترية",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "أظهر تلميحًا قصيرًا بلغتك الأم فوق الكلمات الصعبة. الكلمات التي تفوق مستواك تحصل على تلميح.",
"Level": "المستوى",
"CEFR level": "مستوى CEFR"
"CEFR level": "مستوى CEFR",
"Webtoon Mode": "وضع الويبتون"
}
@@ -1774,5 +1774,6 @@
"biometrics": "বায়োমেট্রিক্স",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "কঠিন শব্দের উপরে আপনার মাতৃভাষায় একটি সংক্ষিপ্ত ইঙ্গিত দেখান। আপনার স্তরের উপরের শব্দগুলো একটি ইঙ্গিত পায়।",
"Level": "স্তর",
"CEFR level": "CEFR স্তর"
"CEFR level": "CEFR স্তর",
"Webtoon Mode": "ওয়েবটুন মোড"
}
@@ -1741,5 +1741,6 @@
"biometrics": "ལུས་རྟགས་ངོ་སྤྲོད།",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "ཚིག་དཀའ་མོའི་སྟེང་དུ་རང་གི་སྐད་ཡིག་ཐོག་ནས་བརྡ་སྟོན་ཐུང་ངུ་ཞིག་སྟོན། ཁྱེད་ཀྱི་རིམ་པ་ལས་མཐོ་བའི་ཚིག་ལ་བརྡ་སྟོན་ཐོབ།",
"Level": "རིམ་པ།",
"CEFR level": "CEFR རིམ་པ།"
"CEFR level": "CEFR རིམ་པ།",
"Webtoon Mode": "Webtoon སྤྱོད་ཚུལ།"
}
@@ -1774,5 +1774,6 @@
"biometrics": "Biometrie",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zeigt einen kurzen Hinweis in deiner Muttersprache über schwierigen Wörtern an. Wörter über deinem Niveau erhalten einen Hinweis.",
"Level": "Niveau",
"CEFR level": "CEFR-Niveau"
"CEFR level": "CEFR-Niveau",
"Webtoon Mode": "Webtoon-Modus"
}
@@ -1774,5 +1774,6 @@
"biometrics": "βιομετρικά",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Εμφανίζει μια σύντομη υπόδειξη στη μητρική σου γλώσσα πάνω από δύσκολες λέξεις. Οι λέξεις πάνω από το επίπεδό σου λαμβάνουν υπόδειξη.",
"Level": "Επίπεδο",
"CEFR level": "Επίπεδο CEFR"
"CEFR level": "Επίπεδο CEFR",
"Webtoon Mode": "Λειτουργία webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biometría",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Muestra una breve pista en tu idioma nativo encima de las palabras difíciles. Las palabras por encima de tu nivel reciben una pista.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR"
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Modo webtoon"
}
@@ -1774,5 +1774,6 @@
"biometrics": "بیومتریک",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "یک راهنمای کوتاه به زبان مادری‌تان بالای واژه‌های دشوار نشان می‌دهد. واژه‌های بالاتر از سطح شما راهنما می‌گیرند.",
"Level": "سطح",
"CEFR level": "سطح CEFR"
"CEFR level": "سطح CEFR",
"Webtoon Mode": "حالت وب‌تون"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biométrie",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Affiche une courte indication dans votre langue maternelle au-dessus des mots difficiles. Les mots au-dessus de votre niveau reçoivent une indication.",
"Level": "Niveau",
"CEFR level": "Niveau CEFR"
"CEFR level": "Niveau CEFR",
"Webtoon Mode": "Mode webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "ביומטריה",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "מציג רמז קצר בשפת האם שלך מעל מילים קשות. מילים מעל הרמה שלך מקבלות רמז.",
"Level": "רמה",
"CEFR level": "רמת CEFR"
"CEFR level": "רמת CEFR",
"Webtoon Mode": "מצב ובטון"
}
@@ -1774,5 +1774,6 @@
"biometrics": "बायोमेट्रिक्स",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "कठिन शब्दों के ऊपर आपकी मातृभाषा में एक छोटा संकेत दिखाता है। आपके स्तर से ऊपर के शब्दों को संकेत मिलता है।",
"Level": "स्तर",
"CEFR level": "CEFR स्तर"
"CEFR level": "CEFR स्तर",
"Webtoon Mode": "वेबटून मोड"
}
@@ -1774,5 +1774,6 @@
"biometrics": "biometria",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Rövid anyanyelvi tippet jelenít meg a nehéz szavak fölött. A szintednél nehezebb szavak kapnak tippet.",
"Level": "Szint",
"CEFR level": "CEFR-szint"
"CEFR level": "CEFR-szint",
"Webtoon Mode": "Webtoon mód"
}
@@ -1741,5 +1741,6 @@
"biometrics": "biometrik",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menampilkan petunjuk singkat dalam bahasa ibu Anda di atas kata-kata sulit. Kata-kata di atas level Anda mendapat petunjuk.",
"Level": "Level",
"CEFR level": "Level CEFR"
"CEFR level": "Level CEFR",
"Webtoon Mode": "Mode webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biometria",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra un breve suggerimento nella tua lingua madre sopra le parole difficili. Le parole sopra il tuo livello ricevono un suggerimento.",
"Level": "Livello",
"CEFR level": "Livello CEFR"
"CEFR level": "Livello CEFR",
"Webtoon Mode": "Modalità webtoon"
}
@@ -1741,5 +1741,6 @@
"biometrics": "生体認証",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "難しい単語の上に母語の短いヒントを表示します。あなたのレベルを超える単語にヒントが付きます。",
"Level": "レベル",
"CEFR level": "CEFRレベル"
"CEFR level": "CEFRレベル",
"Webtoon Mode": "ウェブトゥーンモード"
}
@@ -1741,5 +1741,6 @@
"biometrics": "생체 인증",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "어려운 단어 위에 모국어로 된 짧은 힌트를 표시합니다. 내 수준보다 높은 단어에 힌트가 표시됩니다.",
"Level": "수준",
"CEFR level": "CEFR 수준"
"CEFR level": "CEFR 수준",
"Webtoon Mode": "웹툰 모드"
}
@@ -1741,5 +1741,6 @@
"biometrics": "biometrik",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menunjukkan petunjuk ringkas dalam bahasa ibunda anda di atas perkataan sukar. Perkataan melebihi tahap anda akan mendapat petunjuk.",
"Level": "Tahap",
"CEFR level": "Tahap CEFR"
"CEFR level": "Tahap CEFR",
"Webtoon Mode": "Mode webtoon"
}
@@ -1774,5 +1774,6 @@
"biometrics": "biometrie",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Toont een korte hint in je moedertaal boven moeilijke woorden. Woorden boven jouw niveau krijgen een hint.",
"Level": "Niveau",
"CEFR level": "CEFR-niveau"
"CEFR level": "CEFR-niveau",
"Webtoon Mode": "Webtoon-modus"
}
@@ -1840,5 +1840,6 @@
"biometrics": "biometria",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Pokazuje krótką wskazówkę w Twoim języku ojczystym nad trudnymi słowami. Słowa powyżej Twojego poziomu otrzymują wskazówkę.",
"Level": "Poziom",
"CEFR level": "Poziom CEFR"
"CEFR level": "Poziom CEFR",
"Webtoon Mode": "Tryb webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biometria",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica no seu idioma nativo acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR"
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biometria",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica na sua língua materna acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR"
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
}
@@ -1807,5 +1807,6 @@
"biometrics": "biometrie",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Afișează un scurt indiciu în limba ta maternă deasupra cuvintelor dificile. Cuvintele peste nivelul tău primesc un indiciu.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR"
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Mod webtoon"
}
@@ -1840,5 +1840,6 @@
"biometrics": "биометрия",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показывает короткую подсказку на вашем родном языке над сложными словами. Слова выше вашего уровня получают подсказку.",
"Level": "Уровень",
"CEFR level": "Уровень CEFR"
"CEFR level": "Уровень CEFR",
"Webtoon Mode": "Режим вебтуна"
}
@@ -1774,5 +1774,6 @@
"biometrics": "ජෛව මිතික",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "අසීරු වචන මත ඔබේ මව් භාෂාවෙන් කෙටි ඉඟියක් පෙන්වයි. ඔබේ මට්ටමට වඩා ඉහළ වචනවලට ඉඟියක් ලැබේ.",
"Level": "මට්ටම",
"CEFR level": "CEFR මට්ටම"
"CEFR level": "CEFR මට්ටම",
"Webtoon Mode": "Webtoon ආකාරය"
}
@@ -1840,5 +1840,6 @@
"biometrics": "biometrija",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Prikaže kratek namig v tvojem maternem jeziku nad težkimi besedami. Besede nad tvojo ravnjo dobijo namig.",
"Level": "Raven",
"CEFR level": "Raven CEFR"
"CEFR level": "Raven CEFR",
"Webtoon Mode": "Tryb webtoon"
}
@@ -1774,5 +1774,6 @@
"biometrics": "biometri",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Visar en kort ledtråd på ditt modersmål ovanför svåra ord. Ord över din nivå får en ledtråd.",
"Level": "Nivå",
"CEFR level": "CEFR-nivå"
"CEFR level": "CEFR-nivå",
"Webtoon Mode": "Webtoon-läge"
}
@@ -1774,5 +1774,6 @@
"biometrics": "உயிரியல் அடையாளம்",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "கடினமான சொற்களுக்கு மேலே உங்கள் தாய்மொழியில் ஒரு சிறு குறிப்பைக் காட்டும். உங்கள் நிலைக்கு மேலான சொற்களுக்கு குறிப்பு கிடைக்கும்.",
"Level": "நிலை",
"CEFR level": "CEFR நிலை"
"CEFR level": "CEFR நிலை",
"Webtoon Mode": "வெப்டூன் பயன்முறை"
}
@@ -1741,5 +1741,6 @@
"biometrics": "ข้อมูลไบโอเมตริก",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "แสดงคำใบ้สั้น ๆ ในภาษาแม่ของคุณเหนือคำที่ยาก คำที่สูงกว่าระดับของคุณจะมีคำใบ้",
"Level": "ระดับ",
"CEFR level": "ระดับ CEFR"
"CEFR level": "ระดับ CEFR",
"Webtoon Mode": "โหมดเว็บตูน"
}
@@ -1774,5 +1774,6 @@
"biometrics": "biyometri",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zor kelimelerin üzerinde ana dilinizde kısa bir ipucu gösterir. Seviyenizin üzerindeki kelimeler ipucu alır.",
"Level": "Seviye",
"CEFR level": "CEFR seviyesi"
"CEFR level": "CEFR seviyesi",
"Webtoon Mode": "Webtoon modu"
}
@@ -1840,5 +1840,6 @@
"biometrics": "біометрія",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показує коротку підказку вашою рідною мовою над складними словами. Слова, вищі за ваш рівень, отримують підказку.",
"Level": "Рівень",
"CEFR level": "Рівень CEFR"
"CEFR level": "Рівень CEFR",
"Webtoon Mode": "Режим вебтуна"
}
@@ -1774,5 +1774,6 @@
"biometrics": "biometrik",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Qiyin sozlar ustida ona tilingizda qisqa maslahat korsatadi. Darajangizdan yuqori sozlarga maslahat beriladi.",
"Level": "Daraja",
"CEFR level": "CEFR darajasi"
"CEFR level": "CEFR darajasi",
"Webtoon Mode": "Webtoon rejimi"
}
@@ -1741,5 +1741,6 @@
"biometrics": "sinh trắc học",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Hiển thị gợi ý ngắn bằng tiếng mẹ đẻ của bạn phía trên những từ khó. Những từ trên trình độ của bạn sẽ có gợi ý.",
"Level": "Cấp độ",
"CEFR level": "Cấp độ CEFR"
"CEFR level": "Cấp độ CEFR",
"Webtoon Mode": "Chế độ webtoon"
}
@@ -1741,5 +1741,6 @@
"biometrics": "生物识别",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在生词上方显示一条母语简短提示。高于你水平的单词会显示提示。",
"Level": "级别",
"CEFR level": "CEFR 级别"
"CEFR level": "CEFR 级别",
"Webtoon Mode": "条漫模式"
}
@@ -1741,5 +1741,6 @@
"biometrics": "生物辨識",
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在難詞上方顯示一則母語簡短提示。高於你程度的單字會顯示提示。",
"Level": "等級",
"CEFR level": "CEFR 等級"
"CEFR level": "CEFR 等級",
"Webtoon Mode": "條漫模式"
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { scrollGapToCss } from 'foliate-js/fixed-layout.js';
describe('scrollGapToCss', () => {
it('maps a non-negative number string to a px length', () => {
expect(scrollGapToCss('0')).toBe('0px');
expect(scrollGapToCss('4')).toBe('4px');
expect(scrollGapToCss('12')).toBe('12px');
});
it('returns null for empty / invalid / negative / null so the CSS fallback (4px) applies', () => {
expect(scrollGapToCss('')).toBeNull();
expect(scrollGapToCss('abc')).toBeNull();
expect(scrollGapToCss('-2')).toBeNull();
expect(scrollGapToCss(null)).toBeNull();
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { getScrollGapAttr } from '@/utils/webtoon';
describe('getScrollGapAttr', () => {
it('returns 0 for Webtoon Mode (seamless, no gap)', () => {
expect(getScrollGapAttr(true)).toBe('0');
});
it('returns the default 4 otherwise', () => {
expect(getScrollGapAttr(false)).toBe('4');
});
});
@@ -74,6 +74,7 @@ import { getLocale } from '@/utils/misc';
import { isMetered } from '@/utils/network';
import { eventDispatcher } from '@/utils/event';
import { isFontType } from '@/utils/font';
import { getScrollGapAttr } from '@/utils/webtoon';
import { ParagraphControl } from './paragraph';
import Spinner from '@/components/Spinner';
import KOSyncConflictResolver from './KOSyncResolver';
@@ -702,6 +703,7 @@ const FoliateViewer: React.FC<{
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
view.renderer.setAttribute('scale-factor', viewSettings.zoomLevel);
view.renderer.setAttribute('scroll-gap', getScrollGapAttr(viewSettings.webtoonMode));
} else {
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
@@ -21,6 +21,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { navigateToLogin } from '@/utils/nav';
import { getScrollGapAttr } from '@/utils/webtoon';
import { eventDispatcher } from '@/utils/event';
import { getMaxInlineSize } from '@/utils/config';
import dayjs from 'dayjs';
@@ -54,6 +55,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [webtoonMode, setWebtoonMode] = useState(viewSettings!.webtoonMode ?? false);
const [isParagraphMode, setParagraphMode] = useState(
viewSettings?.paragraphMode?.enabled ?? false,
);
@@ -70,6 +72,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleWebtoonMode = () => setWebtoonMode(!webtoonMode);
const toggleParagraphMode = () => {
setParagraphMode(!isParagraphMode);
eventDispatcher.dispatch('toggle-paragraph-mode', { bookKey });
@@ -119,6 +122,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
if (!isScrolledMode && webtoonMode) setWebtoonMode(false);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
@@ -129,6 +133,23 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
if (webtoonMode === viewSettings.webtoonMode) return;
viewSettings.webtoonMode = webtoonMode;
getView(bookKey)?.renderer.setAttribute('scroll-gap', getScrollGapAttr(webtoonMode));
if (webtoonMode) {
// Webtoon Mode implies scrolled flow + fit-width (scale-factor 100) so pages
// fill the width without horizontal overflow/clipping. Reuse the existing
// scrolled / zoomLevel effects rather than duplicating their renderer wiring.
if (!isScrolledMode) setScrolledMode(true);
if (zoomLevel !== 100) setZoomLevel(100);
saveViewSettings(envConfig, bookKey, 'scrolled', true, false, false);
}
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'webtoonMode', webtoonMode, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webtoonMode]);
useEffect(() => {
if (zoomLevel === viewSettings.zoomLevel) return;
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
@@ -290,6 +311,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
<MenuItem label={_('Webtoon Mode')} toggled={webtoonMode} onClick={toggleWebtoonMode} />
</>
<hr aria-hidden='true' className='border-base-300 my-1' />
</>
@@ -11,6 +11,7 @@ import { eventDispatcher } from '@/utils/event';
import { setShortcutsDialogVisible } from '@/components/KeyboardShortcutsHelp';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { getParagraphActionForKey } from '@/utils/paragraphPresentation';
import { getScrollGapAttr } from '@/utils/webtoon';
import { viewPagination } from './usePagination';
import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
@@ -53,6 +54,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const viewSettings = getViewSettings(sideBarBookKey ?? '');
if (viewSettings && sideBarBookKey) {
viewSettings.scrolled = !viewSettings.scrolled;
// Webtoon Mode requires scrolled flow; leaving scrolled exits Webtoon Mode
// and restores the default page gap (mirror the View menu's behavior).
if (!viewSettings.scrolled && viewSettings.webtoonMode) {
viewSettings.webtoonMode = false;
getView(sideBarBookKey)?.renderer.setAttribute('scroll-gap', getScrollGapAttr(false));
}
setViewSettings(sideBarBookKey, viewSettings!);
const flowMode = viewSettings.scrolled ? 'scrolled' : 'paginated';
getView(sideBarBookKey)?.renderer.setAttribute('flow', flowMode);
@@ -241,6 +241,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
compactMarginRightPx: 16,
gapPercent: 5,
scrolled: false,
webtoonMode: false,
noContinuousScroll: false,
disableClick: false,
disableSwipe: false,
+1
View File
@@ -184,6 +184,7 @@ export interface BookLayout {
compactMarginPx?: number; // deprecated
gapPercent: number;
scrolled: boolean;
webtoonMode: boolean;
noContinuousScroll: boolean;
disableClick: boolean;
disableSwipe: boolean;
+6
View File
@@ -0,0 +1,6 @@
/**
* The `scroll-gap` attribute (px) a fixed-layout renderer should use for the
* current Webtoon Mode state. `0` = seamless webtoon strip; `4` = the default
* inter-page gap. The renderer maps this to the `--scroll-page-gap` CSS var.
*/
export const getScrollGapAttr = (webtoonMode: boolean): string => (webtoonMode ? '0' : '4');