Files
SubMiner/vendor/hachidori/extension/dictionary-groups.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

202 lines
6.8 KiB
JavaScript

/*
* Dictionary-group Settings controls.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./dictionary-group-state.js";
const { normaliseGroupName, groupNameKey } = globalThis.HDDictionaryGroups;
export const normaliseDictionaryGroups = globalThis.HDDictionaryGroups.normaliseDictionaryGroups;
const ALL_GROUP_NAME_KEY = groupNameKey("All");
function element(id) {
return document.getElementById(id);
}
function groupNameError(groups, name, excludedId = null) {
if (name === "") return "Enter a group name.";
const key = groupNameKey(name);
if (key === ALL_GROUP_NAME_KEY) return "All is reserved and cannot be used as a group name.";
if (groups.some((group) =>
group.id !== excludedId && groupNameKey(group.name) === key)) {
return "A group with this name already exists.";
}
return "";
}
function bindMoveButtons(row, prefix, index, length, label, move) {
const up = row.querySelector(`.${prefix}-up`);
const down = row.querySelector(`.${prefix}-down`);
up.dataset.pinnedDisabled = String(index === 0);
down.dataset.pinnedDisabled = String(index === length - 1);
up.setAttribute("aria-label", `Move ${label} up`);
down.setAttribute("aria-label", `Move ${label} down`);
up.addEventListener("click", () => move(-1));
down.addEventListener("click", () => move(1));
}
export function createDictionaryGroupController({
setError,
readState,
readDictionaries,
commitGroups,
dictionaryLabel,
moveListItem,
updateItemById,
renderDeferredAfterBlur,
bindNameDraft,
}) {
function changeNamedGroup(name, excludedId, update) {
void commitGroups((current) => {
const error = groupNameError(current, name, excludedId);
if (error) {
setError(error);
return null;
}
setError("");
return update(current);
});
}
function changeGroup(id, update) {
void commitGroups((current) => updateItemById(current, id, update));
}
function moveGroup(id, step) {
void commitGroups((current) => {
const index = current.findIndex((group) => group.id === id);
return moveListItem(current, index, index + step);
});
}
function moveMember(groupId, dictionaryId, step) {
changeGroup(groupId, (group) => {
const index = group.dictionaryIds.indexOf(dictionaryId);
const dictionaryIds = moveListItem(group.dictionaryIds, index, index + step);
return dictionaryIds === null ? group : { ...group, dictionaryIds };
});
}
function renderMember(group, dictionary, index) {
const row = element("dict-group-member-template").content.firstElementChild.cloneNode(true);
const label = dictionaryLabel(dictionary);
row.dataset.dictionaryId = dictionary.id;
row.querySelector(".dict-group-member-name").textContent = label;
const canonical = row.querySelector(".dict-group-member-canonical");
canonical.textContent = dictionary.displayName ? dictionary.title : "";
canonical.hidden = !dictionary.displayName;
bindMoveButtons(
row,
"dict-group-member",
index,
group.dictionaryIds.length,
`${label} in ${group.name}`,
(step) => moveMember(group.id, dictionary.id, step),
);
const remove = row.querySelector(".dict-group-member-remove");
remove.setAttribute("aria-label", `Remove ${label} from ${group.name}`);
remove.addEventListener("click", () => {
changeGroup(group.id, (current) => ({
...current,
dictionaryIds: current.dictionaryIds.filter((id) => id !== dictionary.id),
}));
});
return row;
}
function bindName(row, group) {
const input = row.querySelector(".dict-group-name");
input.setAttribute("aria-label", `Name for ${group.name}`);
bindNameDraft(input, "groups", group.id, "name", group.name, normaliseGroupName, groupNameError);
renderDeferredAfterBlur(input);
}
function bindAdd(row, group, dictionaries) {
const select = row.querySelector(".dict-group-add-select");
const add = row.querySelector(".dict-group-add");
const available = dictionaries.filter((dictionary) => !group.dictionaryIds.includes(dictionary.id));
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = available.length === 0 ? "All dictionaries added" : "Choose a dictionary";
select.appendChild(placeholder);
for (const dictionary of available) {
const option = document.createElement("option");
option.value = dictionary.id;
option.textContent = dictionaryLabel(dictionary);
select.appendChild(option);
}
select.dataset.pinnedDisabled = String(available.length === 0);
add.dataset.pinnedDisabled = String(available.length === 0);
add.addEventListener("click", () => {
if (select.value === "") return;
const dictionaryId = select.value;
changeGroup(group.id, (current) => ({
...current,
dictionaryIds: [...current.dictionaryIds, dictionaryId],
}));
});
}
function renderRow(group, index, state, dictionaries, installedById) {
const row = element("dict-group-template").content.firstElementChild.cloneNode(true);
row.dataset.groupId = group.id;
bindName(row, group);
const remove = row.querySelector(".dict-group-delete");
remove.setAttribute("aria-label", `Delete ${group.name}`);
bindMoveButtons(
row,
"dict-group",
index,
state.groups.length,
group.name,
(step) => moveGroup(group.id, step),
);
remove.addEventListener("click", () => {
void commitGroups((current) => current.filter((entry) => entry.id !== group.id));
});
bindAdd(row, group, dictionaries);
const members = row.querySelector(".dict-group-members");
group.dictionaryIds.forEach((dictionaryId, memberIndex) => {
members.appendChild(renderMember(group, installedById.get(dictionaryId), memberIndex));
});
row.querySelector(".dict-group-members-empty").hidden = group.dictionaryIds.length > 0;
return row;
}
function render() {
const state = readState();
const dictionaries = readDictionaries();
const installedById = new Map(dictionaries.map((dictionary) => [dictionary.id, dictionary]));
const list = element("dict-group-list");
list.textContent = "";
state.groups.forEach((group, index) => {
list.appendChild(renderRow(group, index, state, dictionaries, installedById));
});
element("dict-group-empty").hidden = state.groups.length > 0;
}
function create() {
const input = element("dict-group-name-new");
const name = normaliseGroupName(input.value);
const error = groupNameError(readState().groups, name);
if (error) {
setError(error);
return;
}
input.value = "";
setError("");
changeNamedGroup(name, null, (current) => [
...current,
{ id: crypto.randomUUID(), name, dictionaryIds: [] },
]);
}
return { create, render };
}