Files
SubMiner/vendor/hachidori/extension/custom-button-settings.js
T
sudacode d9fdc7ef6d 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
2026-09-22 00:21:19 -07:00

233 lines
8.5 KiB
JavaScript

// 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);
},
};
}