feat(plugins): add JavaScript plugin system with overlay panel (#303)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:09:54 -07:00
1 parent 90fc7cf940
commit a9a4c4f083
110 files changed
+5897 -344

No files matched your search

+1
View File
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
changelogKeydownCount += 1;
return true;
},
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
openControllerSelectModal: () => {
openControllerSelectCount += 1;
+6
View File
@@ -32,6 +32,7 @@ export function createKeyboardHandlers(
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -1172,6 +1173,11 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.pluginPanelModalOpen) {
options.handlePluginPanelKeydown(e);
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
return;
+9
View File
@@ -1019,6 +1019,15 @@
</div>
</div>
</div>
<div id="pluginPanelModal" class="modal hidden" aria-hidden="true">
<div class="modal-content plugin-panel-content">
<div class="modal-header">
<div id="pluginPanelTitle" class="modal-title"></div>
<button id="pluginPanelClose" class="modal-close" type="button">Close</button>
</div>
<div id="pluginPanelBody" class="modal-body plugin-panel-body" tabindex="0"></div>
</div>
</div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content">
<div class="modal-header">
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseInlineMarkdown, parsePluginPanelMarkdown } from './plugin-panel-render';
test('parses the block structure of a typical grammar explanation', () => {
const blocks = parsePluginPanelMarkdown(
[
'## Translation',
'I already told you,',
"didn't I?",
'',
'- **言った** past of 言う',
'- `じゃない` seeks agreement',
'',
'1. first',
'2. second',
'',
'> 言ったじゃない',
'---',
'```',
'<b>kept literal</b>',
'```',
].join('\n'),
);
assert.deepEqual(
blocks.map((block) => block.kind),
['heading', 'paragraph', 'list', 'list', 'quote', 'rule', 'code'],
);
assert.deepEqual(blocks[1], {
kind: 'paragraph',
lines: [
[{ kind: 'text', value: 'I already told you,' }],
[{ kind: 'text', value: "didn't I?" }],
],
});
assert.deepEqual(blocks[2], {
kind: 'list',
ordered: false,
items: [
[
{ kind: 'strong', value: '言った' },
{ kind: 'text', value: ' past of 言う' },
],
[
{ kind: 'code', value: 'じゃない' },
{ kind: 'text', value: ' seeks agreement' },
],
],
});
assert.deepEqual(blocks[6], { kind: 'code', value: '<b>kept literal</b>' });
});
test('inline parsing keeps HTML and unmatched markers as text', () => {
assert.deepEqual(parseInlineMarkdown('<img src=x> and *em* and 2 * 3'), [
{ kind: 'text', value: '<img src=x> and ' },
{ kind: 'em', value: 'em' },
{ kind: 'text', value: ' and 2 * 3' },
]);
});
test('underscores inside words stay literal while delimited underscore emphasis still works', () => {
assert.deepEqual(parseInlineMarkdown('set ctx_settings_value and _note_ or __bold__'), [
{ kind: 'text', value: 'set ctx_settings_value and ' },
{ kind: 'em', value: 'note' },
{ kind: 'text', value: ' or ' },
{ kind: 'strong', value: 'bold' },
]);
});
+204
View File
@@ -0,0 +1,204 @@
export type PluginPanelInline =
| { kind: 'text'; value: string }
| { kind: 'code'; value: string }
| { kind: 'strong'; value: string }
| { kind: 'em'; value: string };
export type PluginPanelBlock =
| { kind: 'heading'; level: number; inline: PluginPanelInline[] }
| { kind: 'paragraph'; lines: PluginPanelInline[][] }
| { kind: 'list'; ordered: boolean; items: PluginPanelInline[][] }
| { kind: 'quote'; lines: PluginPanelInline[][] }
| { kind: 'code'; value: string }
| { kind: 'rule' };
// Underscore emphasis needs non-word characters around it, so snake_case names stay literal.
const INLINE_PATTERN =
/`([^`]+)`|\*\*([^*]+)\*\*|(?<!\w)__([^_]+)__(?!\w)|\*([^*\s][^*]*)\*|(?<!\w)_([^_\s][^_]*)_(?!\w)/g;
export function parseInlineMarkdown(text: string): PluginPanelInline[] {
const tokens: PluginPanelInline[] = [];
let lastIndex = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const index = match.index ?? 0;
if (index > lastIndex) tokens.push({ kind: 'text', value: text.slice(lastIndex, index) });
if (match[1] !== undefined) tokens.push({ kind: 'code', value: match[1] });
else if (match[2] !== undefined || match[3] !== undefined) {
tokens.push({ kind: 'strong', value: (match[2] ?? match[3])! });
} else tokens.push({ kind: 'em', value: (match[4] ?? match[5])! });
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) tokens.push({ kind: 'text', value: text.slice(lastIndex) });
return tokens;
}
const HEADING = /^(#{1,6})\s+(.*)$/;
const UNORDERED_ITEM = /^\s*[-*+]\s+(.*)$/;
const ORDERED_ITEM = /^\s*\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
const FENCE = /^\s*```/;
/**
* Parses the markdown subset model replies lean on: headings, lists, quotes, fenced code, rules,
* and paragraphs with code/bold/italic spans. Single newlines inside a paragraph are kept as
* line breaks. Anything else, raw HTML included, stays literal text.
*/
export function parsePluginPanelMarkdown(markdown: string): PluginPanelBlock[] {
const blocks: PluginPanelBlock[] = [];
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
let index = 0;
while (index < lines.length) {
const line = lines[index]!;
if (!line.trim()) {
index += 1;
continue;
}
if (FENCE.test(line)) {
const body: string[] = [];
index += 1;
while (index < lines.length && !FENCE.test(lines[index]!)) {
body.push(lines[index]!);
index += 1;
}
index += 1;
blocks.push({ kind: 'code', value: body.join('\n') });
continue;
}
const heading = HEADING.exec(line);
if (heading) {
blocks.push({
kind: 'heading',
level: heading[1]!.length,
inline: parseInlineMarkdown(heading[2]!.trim()),
});
index += 1;
continue;
}
if (RULE.test(line)) {
blocks.push({ kind: 'rule' });
index += 1;
continue;
}
const ordered = ORDERED_ITEM.test(line);
if (ordered || UNORDERED_ITEM.test(line)) {
const pattern = ordered ? ORDERED_ITEM : UNORDERED_ITEM;
const items: PluginPanelInline[][] = [];
while (index < lines.length) {
const item = pattern.exec(lines[index]!);
if (!item) break;
items.push(parseInlineMarkdown(item[1]!.trim()));
index += 1;
}
blocks.push({ kind: 'list', ordered, items });
continue;
}
if (QUOTE.test(line)) {
const quoted: PluginPanelInline[][] = [];
while (index < lines.length) {
const quote = QUOTE.exec(lines[index]!);
if (!quote) break;
quoted.push(parseInlineMarkdown(quote[1]!));
index += 1;
}
blocks.push({ kind: 'quote', lines: quoted });
continue;
}
const paragraph: PluginPanelInline[][] = [];
while (index < lines.length) {
const next = lines[index]!;
if (
!next.trim() ||
FENCE.test(next) ||
HEADING.test(next) ||
RULE.test(next) ||
UNORDERED_ITEM.test(next) ||
ORDERED_ITEM.test(next) ||
QUOTE.test(next)
) {
break;
}
paragraph.push(parseInlineMarkdown(next.trim()));
index += 1;
}
blocks.push({ kind: 'paragraph', lines: paragraph });
}
return blocks;
}
function appendInline(parent: HTMLElement, tokens: PluginPanelInline[]): void {
for (const token of tokens) {
if (token.kind === 'text') {
parent.append(document.createTextNode(token.value));
continue;
}
const tag = token.kind === 'code' ? 'code' : token.kind;
const element = document.createElement(tag);
element.textContent = token.value;
parent.append(element);
}
}
function appendLines(parent: HTMLElement, lines: PluginPanelInline[][]): void {
lines.forEach((line, index) => {
if (index > 0) parent.append(document.createElement('br'));
appendInline(parent, line);
});
}
/** Builds DOM nodes from parsed blocks. Only text nodes carry content, so nothing is interpreted. */
export function renderPluginPanelMarkdown(container: HTMLElement, markdown: string): void {
container.replaceChildren();
for (const block of parsePluginPanelMarkdown(markdown)) {
switch (block.kind) {
case 'heading': {
// The panel title is the top heading, so content headings start one step down.
const element = document.createElement(`h${Math.min(6, block.level + 2)}`);
appendInline(element, block.inline);
container.append(element);
break;
}
case 'paragraph': {
const element = document.createElement('p');
appendLines(element, block.lines);
container.append(element);
break;
}
case 'list': {
const list = document.createElement(block.ordered ? 'ol' : 'ul');
for (const item of block.items) {
const element = document.createElement('li');
appendInline(element, item);
list.append(element);
}
container.append(list);
break;
}
case 'quote': {
const element = document.createElement('blockquote');
appendLines(element, block.lines);
container.append(element);
break;
}
case 'code': {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = block.value;
pre.append(code);
container.append(pre);
break;
}
case 'rule':
container.append(document.createElement('hr'));
break;
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RendererContext } from '../context';
import { createRendererState } from '../state';
import { createPluginPanelModal } from './plugin-panel';
function createHarness() {
let activeElement: unknown = null;
class TestElement {
textContent = '';
scrollTop = 0;
isConnected = true;
classList = { add() {}, remove() {} };
setAttribute() {}
replaceChildren() {}
addEventListener() {}
removeEventListener() {}
contains() {
return true;
}
focus() {
activeElement = this;
}
getClientRects() {
return [{}];
}
}
const originals = new Map(
['window', 'document', 'Element', 'HTMLElement'].map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
);
const closed: number[] = [];
const dom = {
overlay: new TestElement(),
pluginPanelModal: new TestElement(),
pluginPanelTitle: new TestElement(),
pluginPanelBody: new TestElement(),
pluginPanelClose: new TestElement(),
};
for (const [name, value] of Object.entries({
Element: TestElement,
HTMLElement: TestElement,
document: {
get activeElement() {
return activeElement;
},
addEventListener() {},
removeEventListener() {},
},
window: {
focus() {},
addEventListener() {},
removeEventListener() {},
electronAPI: {
notifyOverlayModalClosed(_modal: string, id: number) {
closed.push(id);
},
},
},
})) {
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const state = createRendererState();
activeElement = dom.pluginPanelBody;
const ctx = {
dom,
state,
platform: { isModalLayer: true, shouldToggleMouseIgnore: false },
} as unknown as RendererContext;
const modal = createPluginPanelModal(ctx, {
modalStateReader: { isAnyModalOpen: () => state.pluginPanelModalOpen },
syncSettingsModalSubtitleSuppression: () => {},
});
return {
modal,
dom,
state,
closed,
restore() {
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
}
test('late opens cannot replace a newer panel or acknowledge a stale instance', () => {
const h = createHarness();
try {
assert.equal(h.modal.openPluginPanelModal({ panelId: 2, title: 'new', markdown: '' }), true);
assert.equal(h.modal.openPluginPanelModal({ panelId: 1, title: 'old', markdown: '' }), false);
assert.equal(h.dom.pluginPanelTitle.textContent, 'new');
h.modal.closePluginPanelModal();
assert.deepEqual(h.closed, [2]);
assert.equal(h.modal.openPluginPanelModal({ panelId: 2, title: 'retry', markdown: '' }), false);
} finally {
h.restore();
}
});
test('a close received before its open prevents the late panel from appearing', () => {
const h = createHarness();
try {
h.modal.closePluginPanelFromMain(1);
assert.equal(
h.modal.openPluginPanelModal({ panelId: 1, title: 'closed', markdown: '' }),
false,
);
assert.equal(h.state.pluginPanelModalOpen, false);
assert.equal(h.modal.openPluginPanelModal({ panelId: 2, title: 'next', markdown: '' }), true);
h.modal.closePluginPanelFromMain(1);
assert.equal(h.state.pluginPanelModalOpen, true);
h.modal.closePluginPanelModal();
} finally {
h.restore();
}
});
+144
View File
@@ -0,0 +1,144 @@
import type { PluginPanelSnapshot } from '../../types/runtime';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
import { renderPluginPanelMarkdown } from './plugin-panel-render';
const SCROLL_STEP_PX = 60;
/** Read-only panel whose content plugins push from the main process through `ctx.ui.panel`. */
export function createPluginPanelModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let panelId: number | null = null;
let latestPanelId = 0;
let closedThroughPanelId = 0;
const focus = createModalFocusGuard({
isOpen: () => ctx.state.pluginPanelModalOpen,
getModalRoot: () => ctx.dom.pluginPanelModal,
getPreferredFocusTargets: () => [ctx.dom.pluginPanelBody],
getFallbackFocusTarget: () => ctx.dom.pluginPanelClose,
isModalLayer: ctx.platform.isModalLayer,
});
function render(snapshot: PluginPanelSnapshot): void {
ctx.dom.pluginPanelTitle.textContent = snapshot.title;
renderPluginPanelMarkdown(ctx.dom.pluginPanelBody, snapshot.markdown);
}
function openPluginPanelModal(snapshot: PluginPanelSnapshot): boolean {
if (snapshot.panelId <= closedThroughPanelId || snapshot.panelId < latestPanelId) return false;
latestPanelId = snapshot.panelId;
panelId = snapshot.panelId;
render(snapshot);
ctx.dom.pluginPanelBody.scrollTop = 0;
if (ctx.state.pluginPanelModalOpen) return true;
priorFocus = document.activeElement;
ctx.state.pluginPanelModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.pluginPanelModal.classList.remove('hidden');
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'false');
ctx.dom.pluginPanelModal.setAttribute('tabindex', '-1');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
focus.attach();
focus.requestOverlayFocus();
window.focus();
focus.enforceModalFocus();
return true;
}
function updatePluginPanel(snapshot: PluginPanelSnapshot): void {
if (!ctx.state.pluginPanelModalOpen || snapshot.panelId !== panelId) return;
render(snapshot);
}
function closePluginPanelModal(): void {
if (!ctx.state.pluginPanelModalOpen) return;
const closedPanelId = panelId ?? undefined;
closedThroughPanelId = Math.max(closedThroughPanelId, panelId ?? 0);
ctx.state.pluginPanelModalOpen = false;
panelId = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.pluginPanelModal.classList.add('hidden');
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('plugin-panel', closedPanelId);
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
focus.detach();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
} else if (ctx.dom.overlay instanceof HTMLElement) {
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
}
/** The plugin closed its panel; ignore requests for a panel that was already replaced. */
function closePluginPanelFromMain(closedPanelId: number): void {
closedThroughPanelId = Math.max(closedThroughPanelId, closedPanelId);
if (closedPanelId === panelId) closePluginPanelModal();
}
function handlePluginPanelKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.pluginPanelModalOpen) return false;
if (e.key === 'Escape') {
e.preventDefault();
closePluginPanelModal();
return true;
}
const body = ctx.dom.pluginPanelBody;
const key = e.key.toLowerCase();
if (key === 'arrowdown' || key === 'j') {
e.preventDefault();
body.scrollBy({ top: SCROLL_STEP_PX });
} else if (key === 'arrowup' || key === 'k') {
e.preventDefault();
body.scrollBy({ top: -SCROLL_STEP_PX });
} else if (key === 'pagedown' || key === ' ') {
e.preventDefault();
body.scrollBy({ top: body.clientHeight * 0.9 });
} else if (key === 'pageup') {
e.preventDefault();
body.scrollBy({ top: -body.clientHeight * 0.9 });
}
return true;
}
function wireDomEvents(): void {
ctx.dom.pluginPanelClose.addEventListener('click', () => {
closePluginPanelModal();
});
}
return {
closePluginPanelModal,
closePluginPanelFromMain,
handlePluginPanelKeydown,
openPluginPanelModal,
updatePluginPanel,
wireDomEvents,
};
}
+6 -4
View File
@@ -3,6 +3,7 @@ import type {
CompiledSessionActionBinding,
CompiledSessionBinding,
SessionActionId,
SessionActionPayload,
SessionKeyModifier,
SessionKeySpec,
} from '../../types';
@@ -189,10 +190,7 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
.join(' + ');
}
function describeSessionAction(
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
switch (actionId) {
case 'toggleStatsOverlay':
return 'Toggle stats overlay';
@@ -254,6 +252,8 @@ function describeSessionAction(
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next'
}`;
case 'runPluginAction':
return `Plugin action ${payload?.pluginActionId ?? ''}`.trim();
}
}
@@ -297,6 +297,8 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Playback and navigation';
case 'cycleRuntimeOption':
return 'Runtime settings';
case 'runPluginAction':
return 'Plugins';
}
}
@@ -102,6 +102,7 @@ export async function setup() {
handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => false,
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
getPlaybackPaused: async () => false,
});
+26
View File
@@ -39,6 +39,7 @@ import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js';
import { createPluginPanelModal } from './modals/plugin-panel.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
@@ -178,6 +179,12 @@ const modalDescriptors = [
close: () => changelogModal.closeChangelogModal(),
suppressesSubtitles: true,
},
{
id: 'plugin-panel',
isOpen: () => ctx.state.pluginPanelModalOpen,
close: () => pluginPanelModal.closePluginPanelModal(),
suppressesSubtitles: true,
},
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
const modalRegistry = createModalRegistry(modalDescriptors);
@@ -253,6 +260,10 @@ const changelogModal = createChangelogModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const pluginPanelModal = createPluginPanelModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () =>
@@ -316,6 +327,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
handlePluginPanelKeydown: pluginPanelModal.handlePluginPanelKeydown,
// Route through main so it can gate help commands on mpv playback state;
// onOpenSessionHelp re-resolves the chord hint when the open event arrives.
openSessionHelpModal: () => {
@@ -590,6 +602,19 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('changelog');
});
});
window.electronAPI.onOpenPluginPanel((snapshot) => {
runGuarded('plugin-panel:open', () => {
if (pluginPanelModal.openPluginPanelModal(snapshot)) {
window.electronAPI.notifyOverlayModalOpened('plugin-panel');
}
});
});
window.electronAPI.onPluginPanelUpdate((snapshot) => {
runGuarded('plugin-panel:update', () => pluginPanelModal.updatePluginPanel(snapshot));
});
window.electronAPI.onPluginPanelClose((panelId) => {
runGuarded('plugin-panel:close', () => pluginPanelModal.closePluginPanelFromMain(panelId));
});
window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => {
if (controllerSelectModal.openControllerSelectModal()) {
@@ -882,6 +907,7 @@ async function init(): Promise<void> {
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
changelogModal.wireDomEvents();
pluginPanelModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
+2
View File
@@ -108,6 +108,7 @@ export type RendererState = {
sessionHelpSelectedIndex: number;
changelogModalOpen: boolean;
changelogSelectedIndex: number;
pluginPanelModalOpen: boolean;
playlistBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string;
@@ -247,6 +248,7 @@ export function createRendererState(): RendererState {
sessionHelpSelectedIndex: 0,
changelogModalOpen: false,
changelogSelectedIndex: 0,
pluginPanelModalOpen: false,
playlistBrowserModalOpen: false,
playlistBrowserSnapshot: null,
playlistBrowserStatus: '',
+88
View File
@@ -4433,6 +4433,94 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
color: var(--ctp-subtext0);
}
.plugin-panel-content {
width: min(760px, 94%);
max-height: 86%;
}
.plugin-panel-body {
display: block;
overflow-y: auto;
max-height: calc(86vh - 110px);
padding-right: 6px;
font-size: 15px;
line-height: 1.6;
color: var(--ctp-text);
outline: none;
user-select: text;
}
.plugin-panel-body > :first-child {
margin-top: 0;
}
.plugin-panel-body p,
.plugin-panel-body ul,
.plugin-panel-body ol,
.plugin-panel-body blockquote,
.plugin-panel-body pre {
margin: 0 0 12px;
}
.plugin-panel-body h3,
.plugin-panel-body h4,
.plugin-panel-body h5,
.plugin-panel-body h6 {
margin: 16px 0 8px;
color: var(--ctp-blue);
font-size: 15px;
font-weight: 700;
}
.plugin-panel-body h3 {
font-size: 17px;
}
.plugin-panel-body ul,
.plugin-panel-body ol {
padding-left: 22px;
}
.plugin-panel-body li + li {
margin-top: 4px;
}
.plugin-panel-body strong {
color: var(--ctp-peach);
}
.plugin-panel-body code {
padding: 1px 5px;
border-radius: 5px;
background: rgba(54, 58, 79, 0.85);
font-size: 0.92em;
}
.plugin-panel-body pre {
padding: 10px 12px;
border-radius: 8px;
background: rgba(30, 32, 48, 0.9);
overflow-x: auto;
white-space: pre-wrap;
}
.plugin-panel-body pre code {
padding: 0;
background: none;
}
.plugin-panel-body blockquote {
padding-left: 12px;
border-left: 3px solid rgba(138, 173, 244, 0.5);
color: var(--ctp-subtext1);
}
.plugin-panel-body hr {
margin: 14px 0;
border: none;
border-top: 1px solid rgba(110, 115, 141, 0.3);
}
@media (max-width: 640px) {
.changelog-list {
max-height: calc(86vh - 200px);
+9
View File
@@ -180,6 +180,11 @@ export type RendererDom = {
changelogStatus: HTMLDivElement;
changelogList: HTMLDivElement;
pluginPanelModal: HTMLDivElement;
pluginPanelTitle: HTMLDivElement;
pluginPanelClose: HTMLButtonElement;
pluginPanelBody: HTMLDivElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
@@ -448,6 +453,10 @@ export function resolveRendererDom(): RendererDom {
changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'),
changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'),
changelogList: getRequiredElement<HTMLDivElement>('changelogList'),
pluginPanelModal: getRequiredElement<HTMLDivElement>('pluginPanelModal'),
pluginPanelTitle: getRequiredElement<HTMLDivElement>('pluginPanelTitle'),
pluginPanelClose: getRequiredElement<HTMLButtonElement>('pluginPanelClose'),
pluginPanelBody: getRequiredElement<HTMLDivElement>('pluginPanelBody'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),