mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
feat(plugins): add JavaScript plugin system with overlay panel (#303)
This commit is contained in:
1 parent
90fc7cf940
commit
a9a4c4f083
110 files changed
+5897
-344
No files matched your search
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
|
||||
changelogKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handlePluginPanelKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
openControllerSelectModal: () => {
|
||||
openControllerSelectCount += 1;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user