mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./external-links.js";
|
||||
|
||||
export function validateLinkButton(label, url) {
|
||||
if (!label || /[\u0000-\u001f\u007f]/u.test(label)) return "Enter a name for the button.";
|
||||
if (!globalThis.HDExternalLinks.expandCustomLinkUrl(url, { word: "word", reading: "reading", sentence: "sentence" })) {
|
||||
return "Enter an http:// or https:// URL. %w, %r and %s are supported.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Retained for callers that validate legacy custom-link drafts directly.
|
||||
export const validateCustomLink = validateLinkButton;
|
||||
|
||||
export function createCustomButtonSettings({
|
||||
document,
|
||||
readButtons,
|
||||
saveButtons,
|
||||
readTemplates,
|
||||
createId = () => document.defaultView.crypto.randomUUID(),
|
||||
}) {
|
||||
const list = document.getElementById("custom-button-list");
|
||||
const empty = document.getElementById("custom-button-empty");
|
||||
const form = document.getElementById("custom-button-form");
|
||||
const name = document.getElementById("opt-custom-button-name");
|
||||
const type = document.getElementById("opt-custom-button-type");
|
||||
const url = document.getElementById("opt-custom-button-url");
|
||||
const template = document.getElementById("opt-custom-button-template");
|
||||
const linkField = document.getElementById("custom-button-link-field");
|
||||
const ankiField = document.getElementById("custom-button-anki-field");
|
||||
const submit = document.getElementById("custom-button-submit");
|
||||
const cancel = document.getElementById("custom-button-cancel");
|
||||
const status = document.getElementById("custom-buttons-status");
|
||||
let editing = null;
|
||||
let rendered = null;
|
||||
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
||||
const templates = () => readTemplates().filter(value => value && typeof value.id === "string");
|
||||
const templateById = id => templates().find(value => value.id === id);
|
||||
|
||||
function button(text, label, onClick, iconName) {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button";
|
||||
node.className = "ghost";
|
||||
if (iconName) {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "hd-icon";
|
||||
icon.dataset.icon = iconName;
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
node.append(icon);
|
||||
} else {
|
||||
node.textContent = text;
|
||||
}
|
||||
node.setAttribute("aria-label", label);
|
||||
node.addEventListener("click", onClick);
|
||||
return node;
|
||||
}
|
||||
|
||||
function focusRow(index, action) {
|
||||
const row = list.children[Math.min(index, list.children.length - 1)];
|
||||
(row?.querySelector(`[data-action="${action}"]:not(:disabled)`)
|
||||
?? row?.querySelector("button:not(:disabled)") ?? name).focus();
|
||||
}
|
||||
|
||||
function move(index, offset) {
|
||||
const buttons = readButtons().slice();
|
||||
[buttons[index], buttons[index + offset]] = [buttons[index + offset], buttons[index]];
|
||||
if (editing?.index === index) editing.index += offset;
|
||||
else if (editing?.index === index + offset) editing.index -= offset;
|
||||
saveButtons(buttons);
|
||||
render();
|
||||
focusRow(index + offset, offset < 0 ? "up" : "down");
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
if (editing?.index === index) closeForm();
|
||||
else if (editing && editing.index > index) editing.index -= 1;
|
||||
saveButtons(readButtons().filter((_button, buttonIndex) => buttonIndex !== index));
|
||||
render();
|
||||
focusRow(index, "edit");
|
||||
}
|
||||
|
||||
function edit(index) {
|
||||
const value = readButtons()[index];
|
||||
editing = { index, button: value };
|
||||
name.value = value.label;
|
||||
type.value = value.type;
|
||||
url.value = value.type === "link" ? value.url : "";
|
||||
template.value = value.type === "anki" ? value.templateId : "";
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
name.focus();
|
||||
}
|
||||
|
||||
function createRow(value, index, count) {
|
||||
const row = document.createElement("li");
|
||||
row.className = "custom-button-row";
|
||||
const text = document.createElement("div");
|
||||
text.className = "custom-button-text";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = value.label;
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "custom-button-summary";
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "custom-button-kind";
|
||||
kind.textContent = value.type === "link" ? "Link" : "Anki";
|
||||
const detail = document.createElement(value.type === "link" ? "code" : "span");
|
||||
if (value.type === "link") detail.textContent = value.url;
|
||||
else detail.textContent = templateById(value.templateId)?.name ?? "Template unavailable";
|
||||
summary.append(kind, " · ", detail);
|
||||
text.append(label, summary);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "custom-button-actions";
|
||||
const up = button("", `Move up: ${value.label}`, () => move(index, -1), "arrow-up");
|
||||
const down = button("", `Move down: ${value.label}`, () => move(index, 1), "arrow-down");
|
||||
up.dataset.action = "up";
|
||||
down.dataset.action = "down";
|
||||
up.disabled = index === 0;
|
||||
down.disabled = index === count - 1;
|
||||
const editButton = button("Edit", `Edit button ${value.label}`, () => edit(index));
|
||||
editButton.dataset.action = "edit";
|
||||
actions.append(up, down, editButton, button("Delete", `Delete button ${value.label}`, () => remove(index)));
|
||||
row.append(text, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderTemplateChoices() {
|
||||
const values = templates();
|
||||
const selected = template.value;
|
||||
const choices = values.map(value => new document.defaultView.Option(value.name, value.id));
|
||||
if (selected && !values.some(value => value.id === selected)) {
|
||||
choices.push(new document.defaultView.Option("Unavailable Template", selected));
|
||||
}
|
||||
template.replaceChildren(...choices);
|
||||
template.value = selected && choices.some(option => option.value === selected)
|
||||
? selected : values[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function updateForm() {
|
||||
renderTemplateChoices();
|
||||
const link = type.value === "link";
|
||||
linkField.hidden = !link;
|
||||
ankiField.hidden = link;
|
||||
submit.textContent = editing ? "Save button" : "Add button";
|
||||
cancel.hidden = !editing;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
editing = null;
|
||||
name.value = url.value = "";
|
||||
type.value = "link";
|
||||
template.value = templates()[0]?.id ?? "";
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const buttons = readButtons();
|
||||
const key = JSON.stringify([buttons, templates().map(({ id, name: templateName }) => [id, templateName])]);
|
||||
if (key === rendered) return;
|
||||
rendered = key;
|
||||
list.replaceChildren(...buttons.map((value, index) => createRow(value, index, buttons.length)));
|
||||
empty.hidden = buttons.length > 0;
|
||||
renderTemplateChoices();
|
||||
}
|
||||
|
||||
function uniqueId() {
|
||||
const ids = new Set(readButtons().map(value => value.id));
|
||||
let id;
|
||||
do id = createId();
|
||||
while (typeof id !== "string" || id === "" || ids.has(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
function draft() {
|
||||
const base = {
|
||||
id: editing?.button.id ?? "",
|
||||
type: type.value,
|
||||
label: name.value.trim(),
|
||||
};
|
||||
return type.value === "anki"
|
||||
? { ...base, templateId: template.value }
|
||||
: { ...base, url: url.value.trim() };
|
||||
}
|
||||
|
||||
form.addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
const value = draft();
|
||||
let error = "";
|
||||
if (value.type === "link") error = validateLinkButton(value.label, value.url);
|
||||
else if (!value.label || /[\u0000-\u001f\u007f]/u.test(value.label)) error = "Enter a name for the button.";
|
||||
else if (!templateById(value.templateId)) error = "Choose an available Template.";
|
||||
if (error) {
|
||||
status.textContent = error;
|
||||
return;
|
||||
}
|
||||
const buttons = readButtons().slice();
|
||||
if (editing && !same(buttons[editing.index], editing.button)) {
|
||||
status.textContent = "This button changed elsewhere. Cancel, then edit it again.";
|
||||
return;
|
||||
}
|
||||
if (editing) buttons[editing.index] = value;
|
||||
else buttons.push({ ...value, id: uniqueId() });
|
||||
saveButtons(buttons);
|
||||
closeForm();
|
||||
render();
|
||||
name.focus();
|
||||
});
|
||||
for (const input of [name, url, template]) {
|
||||
input.addEventListener("input", () => { status.textContent = ""; });
|
||||
input.addEventListener("change", () => { status.textContent = ""; });
|
||||
}
|
||||
type.addEventListener("change", () => {
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
closeForm();
|
||||
name.focus();
|
||||
});
|
||||
updateForm();
|
||||
render();
|
||||
return {
|
||||
render,
|
||||
reset() { closeForm(); render(); },
|
||||
dirty: () => {
|
||||
const value = draft();
|
||||
if (!editing) return value.label !== "" || (value.type === "link" ? value.url !== "" : false);
|
||||
return !same(value, editing.button);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user