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:
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user