Files
rd.rdlevel.cn/script/custommethod-search.ts
T
2026-10-05 18:55:40 +08:00

748 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
enum ParameterType {
String = "string",
Number = "number",
Boolean = "boolean",
Enum = "enum",
}
enum MethodType {
Mod = "mod",
Surgery = "surgery",
Comment = "comment",
CustomMethod = "custom_method",
Ink = "ink",
Variable = "variable",
Condition = "condition",
CustomClass = "custom_class",
}
type ValueMode = "constant" | "expression";
type StringStrategy = "prefix" | "quote";
const enumTypeMap: Record<string, string[]> = {
Characters: ["Logan", "Green", "Blue", "Yellow", "Purple", "Orange"],
Size: ["Small", "Medium", "Large"],
Shape: ["Circle", "Square", "Triangle"],
};
class NumericRange {
min?: number;
max?: number;
isInteger: boolean;
public constructor(min?: number, max?: number, isInteger: boolean = false) {
this.min = min;
this.max = max;
this.isInteger = isInteger;
}
}
class Parameter {
name: string;
type: ParameterType;
enumType?: string;
numericRange?: NumericRange;
description: string;
public constructor(name: string, type: ParameterType, description: string, enumType?: string, numericRange?: NumericRange) {
this.name = name;
this.type = type;
this.description = description;
this.enumType = enumType;
this.numericRange = numericRange;
}
}
class Method {
name: string;
type: MethodType;
shortDescription: string;
description: string;
public constructor(name: string, type: MethodType, shortDescription: string, description: string) {
this.name = name;
this.type = type;
this.shortDescription = shortDescription;
this.description = description;
}
}
class Mod extends Method {
public constructor(name: string, shortDescription: string, description: string) {
super(name, MethodType.Mod, shortDescription, description);
}
}
class Surgery extends Method {
public constructor(name: string, shortDescription: string, description: string) {
super(name, MethodType.Surgery, shortDescription, description);
}
}
class CommentMethod extends Method {
parameters: Parameter[];
public constructor(name: string, shortDescription: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Comment, shortDescription, description);
this.parameters = parameters;
}
}
class InkMethod extends Method {
parameters: Parameter[];
public constructor(name: string, shortDescription: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Ink, shortDescription, description);
this.parameters = parameters;
}
}
class CustomMethods extends Method {
parameters: Parameter[];
public constructor(name: string, shortDescription: string, description: string, parameters: Parameter[]) {
super(name, MethodType.CustomMethod, shortDescription, description);
this.parameters = parameters;
}
}
class VariableMethod extends Method {
parameters: Parameter[];
public constructor(name: string, shortDescription: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Variable, shortDescription, description);
this.parameters = parameters;
}
}
class ConditionMethod extends Method {
parameters: Parameter[];
public constructor(name: string, shortDescription: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Condition, shortDescription, description);
this.parameters = parameters;
}
}
class CustomClassMethod extends Method {
public constructor(name: string, shortDescription: string, description: string) {
super(name, MethodType.CustomClass, shortDescription, description);
}
}
function editDistance(a: string, b: string, caseSensitive: boolean = true): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const ac = a.charAt(j - 1);
const bc = b.charAt(i - 1);
if (caseSensitive ? bc === ac : bc.toLowerCase() === ac.toLowerCase()) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1),
);
}
}
}
return matrix[b.length][a.length];
}
function parametersOf(method: Method): Parameter[] {
return (method as CommentMethod | InkMethod | CustomMethods | VariableMethod | ConditionMethod).parameters ?? [];
}
// 全局策略:字符串写法、数字/布尔默认的常量/表达式状态
const strategies: { string: StringStrategy; number: ValueMode; boolean: ValueMode } = {
string: "prefix",
number: "constant",
boolean: "constant",
};
// ---------------------------------------------------------------------
// 外置数据 hydrate(script/custommethod-data.js)
// ---------------------------------------------------------------------
function hydrateMethod(raw: any): Method {
const shortDescription = raw.shortDescription ?? raw.description ?? "";
const description = raw.description ?? raw.shortDescription ?? "";
const parameters: Parameter[] = Array.isArray(raw.parameters)
? raw.parameters.map((item: any) => new Parameter(
item.name,
item.type ?? ParameterType.String,
item.description ?? "",
item.enumType,
item.numericRange
? new NumericRange(item.numericRange.min, item.numericRange.max, item.numericRange.isInteger)
: undefined,
))
: [];
switch (raw.type) {
case MethodType.Mod: return new Mod(raw.name, shortDescription, description);
case MethodType.Surgery: return new Surgery(raw.name, shortDescription, description);
case MethodType.Comment: return new CommentMethod(raw.name, shortDescription, description, parameters);
case MethodType.Ink: return new InkMethod(raw.name, shortDescription, description, parameters);
case MethodType.Variable: return new VariableMethod(raw.name, shortDescription, description, parameters);
case MethodType.Condition: return new ConditionMethod(raw.name, shortDescription, description, parameters);
case MethodType.CustomClass: return new CustomClassMethod(raw.name, shortDescription, description);
default: return new CustomMethods(raw.name, shortDescription, description, parameters);
}
}
let methods: Method[] = [];
function loadExternalData(): void {
const data = (window as any).CustomMethodSearchData;
if (!data) return;
if (data.enumTypes) {
for (const key of Object.keys(data.enumTypes)) {
enumTypeMap[key] = data.enumTypes[key];
}
}
if (Array.isArray(data.methods)) {
const index = new Map<string, number>();
methods.forEach((method, i) => index.set(method.name.toLowerCase(), i));
for (const raw of data.methods) {
if (!raw || !raw.name) continue;
const name = String(raw.name).toLowerCase();
const hydrated = hydrateMethod(raw);
if (index.has(name)) {
methods[index.get(name)!] = hydrated;
} else {
index.set(name, methods.length);
methods.push(hydrated);
}
}
}
}
loadExternalData();
// ---------------------------------------------------------------------
// 匹配
// ---------------------------------------------------------------------
function searchMethods(query: string): Method[] {
const q = query.trim();
if (!q) return [];
const ql = q.toLowerCase();
const maxDistance = Math.max(2, Math.floor(q.length / 2));
return methods
.map((method) => {
const name = method.name;
const lower = name.toLowerCase();
let score: number;
if (name === q) {
score = 0;
} else if (lower.startsWith(ql)) {
score = 1;
} else if (lower.indexOf(ql) >= 0) {
score = 2;
} else {
score = 3 + editDistance(name, q, false);
}
return { method, score };
})
.filter((item) => item.score <= 2 || item.score <= 3 + maxDistance)
.sort((a, b) => a.score - b.score || a.method.name.localeCompare(b.method.name))
.slice(0, 8)
.map((item) => item.method);
}
// ---------------------------------------------------------------------
// DOM 工具
// ---------------------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
return node;
}
function resolveEnumOptions(parameter: Parameter): string[] {
if (parameter.enumType && enumTypeMap[parameter.enumType]) return enumTypeMap[parameter.enumType];
if (enumTypeMap[parameter.description]) return enumTypeMap[parameter.description];
if (enumTypeMap[parameter.name]) return enumTypeMap[parameter.name];
return [];
}
// 布尔可用 true/false,或布尔变量 b0~b9
const BOOLEAN_OPTIONS: string[] = ["true", "false"];
for (let i = 0; i < 10; i++) {
BOOLEAN_OPTIONS.push("b" + i);
}
function makeCombo(parameter: Parameter, options: string[], onChange: () => void): HTMLElement {
const combo = el("span", "cm-combo");
const input = el("input", "cm-input cm-combo-input") as HTMLInputElement;
input.type = "text";
input.placeholder = parameter.name;
input.autocomplete = "off";
const list = el("ul", "cm-combo-list");
list.hidden = true;
function populate(): void {
list.textContent = "";
if (options.length === 0) { list.hidden = true; return; }
const query = input.value.trim();
const ql = query.toLowerCase();
const maxDistance = Math.max(1, Math.floor(query.length / 2));
const shown = (query
? options.filter((option) =>
option.toLowerCase().indexOf(ql) >= 0 ||
editDistance(option, query, false) <= maxDistance)
: options
).slice(0, 10);
if (shown.length === 0) { list.hidden = true; return; }
for (const option of shown) {
const item = el("li", "cm-combo-item");
item.textContent = option;
item.title = option;
item.addEventListener("mousedown", (event) => {
event.preventDefault();
input.value = option;
list.hidden = true;
onChange();
});
list.appendChild(item);
}
list.hidden = false;
}
input.addEventListener("input", () => { populate(); onChange(); });
input.addEventListener("focus", populate);
input.addEventListener("blur", () => { window.setTimeout(() => { list.hidden = true; }, 120); });
combo.appendChild(input);
combo.appendChild(list);
return combo;
}
function makeModeToggle(initial: ValueMode, onChange: () => void): { el: HTMLButtonElement; get: () => ValueMode } {
const button = el("button", "cm-mode") as HTMLButtonElement;
button.type = "button";
let mode = initial;
const render = (): void => {
button.textContent = mode === "expression" ? "表达式" : "常量";
button.title = "切换 常量 / 表达式";
button.classList.toggle("is-expression", mode === "expression");
};
render();
button.addEventListener("click", (event) => {
event.preventDefault();
mode = mode === "expression" ? "constant" : "expression";
render();
onChange();
});
return { el: button, get: () => mode };
}
interface ParamState {
text: string;
valid: boolean;
invalid: boolean;
}
interface Field {
el: HTMLElement;
read: () => ParamState;
}
function stringValue(value: string): string {
if (value.length === 0) return "<文本>";
return strategies.string === "quote" ? `"${value}"` : `str:${value}`;
}
function makeField(parameter: Parameter, onChange: () => void): Field {
switch (parameter.type) {
case ParameterType.Boolean: {
const wrap = el("span", "cm-field");
const checkbox = el("input", "cm-input cm-check") as HTMLInputElement;
checkbox.type = "checkbox";
checkbox.addEventListener("change", onChange);
const combo = makeCombo(parameter, BOOLEAN_OPTIONS, onChange);
const comboInput = combo.querySelector("input") as HTMLInputElement;
let mode: ValueMode = strategies.boolean;
const toggle = makeModeToggle(strategies.boolean, () => {
mode = toggle.get();
applyMode();
onChange();
});
function applyMode(): void {
const expression = mode === "expression";
checkbox.hidden = expression;
combo.hidden = !expression;
}
applyMode();
wrap.appendChild(toggle.el);
wrap.appendChild(checkbox);
wrap.appendChild(combo);
return {
el: wrap,
read: () => {
if (mode === "constant") {
return { text: checkbox.checked ? "true" : "false", valid: true, invalid: false };
}
const value = comboInput.value.trim();
return value.length > 0
? { text: value, valid: true, invalid: false }
: { text: "<布尔>", valid: false, invalid: true };
},
};
}
case ParameterType.Number: {
const wrap = el("span", "cm-field");
const input = el("input", "cm-input") as HTMLInputElement;
input.type = "text";
input.inputMode = "decimal";
input.placeholder = parameter.name;
input.addEventListener("input", onChange);
const mode = makeModeToggle(strategies.number, onChange);
wrap.appendChild(mode.el);
wrap.appendChild(input);
return {
el: wrap,
read: () => {
const value = input.value.trim();
if (value.length === 0) return { text: "<数字>", valid: false, invalid: true };
if (mode.get() === "expression") return { text: value, valid: true, invalid: false };
const number = Number(value);
let valid = isFinite(number);
const range = parameter.numericRange;
if (valid && range) {
if (range.isInteger && !Number.isInteger(number)) valid = false;
if (range.min !== undefined && number < range.min) valid = false;
if (range.max !== undefined && number > range.max) valid = false;
}
return { text: value, valid, invalid: !valid };
},
};
}
case ParameterType.Enum: {
const wrap = el("span", "cm-field cm-field-string");
const open = el("span", "cm-lit");
const close = el("span", "cm-lit");
open.textContent = strategies.string === "quote" ? "\"" : "str:";
close.textContent = strategies.string === "quote" ? "\"" : "";
const combo = makeCombo(parameter, resolveEnumOptions(parameter), onChange);
const input = combo.querySelector("input") as HTMLInputElement;
wrap.appendChild(open);
wrap.appendChild(combo);
wrap.appendChild(close);
return {
el: wrap,
read: () => {
const value = input.value.trim();
const options = resolveEnumOptions(parameter);
const valid = options.length > 0
? options.some((option) => option.toLowerCase() === value.toLowerCase())
: value.length > 0;
return { text: stringValue(value), valid, invalid: !valid };
},
};
}
default: {
const wrap = el("span", "cm-field cm-field-string");
const open = el("span", "cm-lit");
const close = el("span", "cm-lit");
open.textContent = strategies.string === "quote" ? "\"" : "str:";
close.textContent = strategies.string === "quote" ? "\"" : "";
const input = el("input", "cm-input") as HTMLInputElement;
input.type = "text";
input.placeholder = parameter.name;
input.addEventListener("input", onChange);
wrap.appendChild(open);
wrap.appendChild(input);
wrap.appendChild(close);
return {
el: wrap,
read: () => {
const value = input.value.trim();
return { text: stringValue(value), valid: value.length > 0, invalid: value.length === 0 };
},
};
}
}
}
interface Skeleton {
prefix: string;
suffix: string;
}
function methodSkeleton(method: Method, hasParams: boolean): Skeleton {
switch (method.type) {
case MethodType.Comment: return { prefix: `()=>${method.name}(`, suffix: ")" };
case MethodType.Ink: return { prefix: `DOTHIS{${method.name}(`, suffix: ")}" };
case MethodType.Variable: return { prefix: `${method.name} = `, suffix: "" };
case MethodType.Mod: return { prefix: `"mods": [..., "`, suffix: `"]` };
case MethodType.CustomClass: return { prefix: `"customClass": "`, suffix: `"` };
case MethodType.Condition:
return hasParams ? { prefix: `${method.name}(`, suffix: ")" } : { prefix: method.name, suffix: "" };
case MethodType.Surgery: return { prefix: method.name, suffix: "" };
default:
return hasParams ? { prefix: `${method.name}(`, suffix: ")" } : { prefix: method.name, suffix: "" };
}
}
function formatMethod(method: Method, states: ParamState[]): string {
const args = states.map((state) => state.text).join(", ");
switch (method.type) {
case MethodType.Comment: return `()=>${method.name}(${args})`;
case MethodType.Ink: return `DOTHIS{${method.name}(${args})}`;
case MethodType.Variable: return `${method.name} = ${states.length ? states[0].text : ""}`;
case MethodType.Mod: return `"mods": [..., "${method.name}"]`;
case MethodType.CustomClass: return `"customClass": "${method.name}"`;
case MethodType.Condition: return states.length ? `${method.name}(${args})` : method.name;
case MethodType.Surgery: return method.name;
default: return states.length ? `${method.name}(${args})` : method.name;
}
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
// fall through
}
try {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
document.body.removeChild(area);
return ok;
} catch (error) {
return false;
}
}
// ---------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------
function renderContainer(container: HTMLElement): void {
if (container.dataset.cmReady === "1") return;
container.dataset.cmReady = "1";
let currentMethod: Method | null = null;
let query = container.querySelector<HTMLInputElement>(".cm-query");
let suggest = container.querySelector<HTMLUListElement>(".cm-suggest");
let selected = container.querySelector<HTMLElement>(".cm-selected");
let nameEl = container.querySelector<HTMLElement>(".cm-name");
let shortEl = container.querySelector<HTMLElement>(".cm-short");
let call = container.querySelector<HTMLElement>(".cm-call");
let preview = container.querySelector<HTMLElement>(".cm-preview");
let desc = container.querySelector<HTMLElement>(".cm-desc");
let options = container.querySelector<HTMLElement>(".cm-options");
if (!query || !suggest || !selected || !nameEl || !shortEl || !call || !preview || !desc) {
container.textContent = "";
const searchBar = el("div", "cm-search");
query = el("input", "cm-query") as HTMLInputElement;
query.type = "text";
query.placeholder = "输入自定义方法名,如 ChangeCharacter";
query.autocomplete = "off";
suggest = el("ul", "cm-suggest");
searchBar.appendChild(query);
searchBar.appendChild(suggest);
selected = el("div", "cm-selected");
selected.hidden = true;
const head = el("div", "cm-head");
nameEl = el("span", "cm-name");
shortEl = el("span", "cm-short");
head.appendChild(nameEl);
head.appendChild(shortEl);
call = el("div", "cm-call");
preview = el("code", "cm-preview");
desc = el("div", "cm-desc");
selected.appendChild(head);
selected.appendChild(call);
selected.appendChild(preview);
selected.appendChild(desc);
container.appendChild(searchBar);
container.appendChild(selected);
}
// 搜索框下的策略选项
options = container.querySelector<HTMLElement>(".cm-options");
if (!options) {
options = el("div", "cm-options");
(query.parentElement as HTMLElement).insertAdjacentElement("afterend", options);
}
if (!options.querySelector(".cm-option-select")) {
const optionsBox = options;
function addOption(label: string, key: "string" | "number" | "boolean", values: [string, string][], current: string): void {
const wrap = el("label", "cm-option");
const text = el("span", "cm-option-label");
text.textContent = label;
const select = el("select", "cm-option-select") as HTMLSelectElement;
for (const [value, caption] of values) {
const opt = el("option");
opt.value = value;
opt.textContent = caption;
select.appendChild(opt);
}
select.value = current;
select.addEventListener("change", () => {
(strategies as any)[key] = select.value;
if (currentMethod) { selectMethod(currentMethod); }
});
wrap.appendChild(text);
wrap.appendChild(select);
optionsBox.appendChild(wrap);
}
addOption("字符串", "string", [["prefix", "str: 前缀"], ["quote", "引号 \"…\""]], strategies.string);
}
const queryEl = query;
const suggestEl = suggest;
const selectedEl = selected;
const nameNode = nameEl;
const shortNode = shortEl;
const callEl = call;
const previewEl = preview;
const descNode = desc;
const copyBtn = el("button", "cm-copy") as HTMLButtonElement;
copyBtn.type = "button";
copyBtn.textContent = "复制";
copyBtn.hidden = true;
copyBtn.title = "复制生成的代码";
selectedEl.appendChild(copyBtn);
copyBtn.addEventListener("click", () => {
copyText(previewEl.textContent ?? "").then((ok) => {
copyBtn.textContent = ok ? "已复制" : "复制失败";
window.setTimeout(() => { copyBtn.textContent = "复制"; }, 1200);
});
});
function clearSuggest(): void {
suggestEl.textContent = "";
}
function selectMethod(method: Method): void {
currentMethod = method;
clearSuggest();
queryEl.value = method.name;
selectedEl.hidden = false;
nameNode.textContent = method.name;
shortNode.textContent = method.shortDescription || "";
descNode.textContent = method.description || "";
callEl.textContent = "";
previewEl.textContent = "";
copyBtn.hidden = true;
const parameters = parametersOf(method);
const fields: Field[] = [];
function updatePreview(): void {
const states = fields.map((field) => field.read());
fields.forEach((field, i) => field.el.classList.toggle("cm-invalid", states[i].invalid));
previewEl.textContent = formatMethod(method, states);
copyBtn.hidden = !states.every((state) => state.valid);
}
const skeleton = methodSkeleton(method, parameters.length > 0);
const code = el("code", "cm-code");
const prefix = el("span", "cm-lit");
prefix.textContent = skeleton.prefix;
code.appendChild(prefix);
parameters.forEach((parameter, index) => {
if (index > 0) {
const sep = el("span", "cm-lit");
sep.textContent = ", ";
code.appendChild(sep);
}
const field = makeField(parameter, updatePreview);
field.el.title = parameter.description;
code.appendChild(field.el);
fields.push(field);
});
if (skeleton.suffix) {
const suffix = el("span", "cm-lit");
suffix.textContent = skeleton.suffix;
code.appendChild(suffix);
}
callEl.appendChild(code);
if (parameters.length === 0) {
const empty = el("span", "cm-empty");
empty.textContent = "(无参数)";
callEl.appendChild(empty);
}
updatePreview();
}
function updateSuggestions(): void {
clearSuggest();
for (const method of searchMethods(queryEl.value)) {
const item = el("li", "cm-suggest-item");
const nameSpan = el("span", "cm-suggest-name");
nameSpan.textContent = method.name;
const descSpan = el("span", "cm-suggest-desc");
descSpan.textContent = method.shortDescription || method.description;
item.appendChild(nameSpan);
item.appendChild(descSpan);
item.addEventListener("mousedown", (event) => {
event.preventDefault();
selectMethod(method);
});
suggestEl.appendChild(item);
}
}
queryEl.addEventListener("input", () => {
updateSuggestions();
selectedEl.hidden = true;
});
queryEl.addEventListener("focus", updateSuggestions);
queryEl.addEventListener("blur", () => {
window.setTimeout(clearSuggest, 120);
});
if (queryEl.value) {
updateSuggestions();
}
}
function initAll(): void {
document.querySelectorAll<HTMLElement>(".custommethods-search").forEach((container) => renderContainer(container));
}
function register(): void {
const docsify = (window as any).$docsify;
if (docsify) {
docsify.plugins = ([] as any[]).concat(docsify.plugins, (hook: any) => {
hook.doneEach(() => initAll());
});
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initAll());
} else {
initAll();
}
}
(window as any).CustomMethodSearch = {
search: searchMethods,
editDistance,
renderAll: initAll,
strategies,
getMethods: () => methods,
setMethods: (list: Method[]) => { methods = list; initAll(); },
Parameter,
ParameterType,
Method,
MethodType,
Mod,
Surgery,
CommentMethod,
InkMethod,
CustomMethods,
VariableMethod,
ConditionMethod,
CustomClassMethod,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", register);
} else {
register();
}
})();