mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
233 lines
8.5 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|