From 0d6f26c8d97ba4f6ae0d8df5818a1f3a314e4a11 Mon Sep 17 00:00:00 2001 From: Silence_Lurker Date: Fri, 13 Feb 2026 13:04:39 +0800 Subject: [PATCH] 2602 dev init --- SL/module/Module.js | 351 +++++++++++++++++++++++++++++++++++++++ SL/module/TableModule.js | 22 +++ 2 files changed, 373 insertions(+) create mode 100644 SL/module/Module.js create mode 100644 SL/module/TableModule.js diff --git a/SL/module/Module.js b/SL/module/Module.js new file mode 100644 index 0000000..a57379e --- /dev/null +++ b/SL/module/Module.js @@ -0,0 +1,351 @@ +export class Module { + constructor(builder) { + if (!builder || typeof builder.build !== 'function') { + throw new Error('[Module] Invalid builder.'); + } + this.builder = builder; + this.config = builder.build(); + this.ctx = null; + this.el = null; + this.viewEl = null; + this._bindings = []; + this._disposables = []; + this._state = { ...(this.config.state || {}) }; + } + + async init(ctx = {}) { + this.ctx = ctx; + await this._loadView(); + this._bindAll(); + return this; + } + + async mount() { + if (this._isStrict('mount')) { + this._abstract('mount'); + } + } + + dispose() { + if (this._isStrict('dispose')) { + this._abstract('dispose'); + } + this._unbindAll(); + for (const d of this._disposables) { + try { d(); } catch (_) { /* noop */ } + } + this._disposables = []; + } + + expose() { + return {}; + } + + getState() { + return { ...this._state }; + } + + setState(next) { + if (!next || typeof next !== 'object') return; + Object.assign(this._state, next); + this._applyStateToBindings(); + } + + registerDisposable(fn) { + if (typeof fn === 'function') { + this._disposables.push(fn); + } + } + + _abstract(methodName) { + throw new Error(`[Module] Method not implemented: ${methodName}`); + } + + _isStrict(methodName) { + if (!this.config.strict) return false; + const required = this.config.requiredMethods || []; + return required.includes(methodName); + } + + async _loadView() { + const viewPath = this.config.view; + if (!viewPath) return; + + const rootTarget = this._resolveRoot(); + if (!rootTarget) { + throw new Error('[Module] Root element not found.'); + } + + const url = this._resolveViewUrl(viewPath); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`[Module] Failed to load view: ${viewPath}`); + } + const html = await res.text(); + + const wrapper = document.createElement('div'); + wrapper.className = this.config.wrapperClass || 'amily2-module'; + wrapper.dataset.module = this.config.name || 'Module'; + wrapper.innerHTML = html; + + rootTarget.appendChild(wrapper); + this.el = wrapper; + this.viewEl = wrapper; + } + + _resolveRoot() { + if (this.config.rootSelector) { + return document.querySelector(this.config.rootSelector); + } + if (this.ctx && this.ctx.root instanceof HTMLElement) { + return this.ctx.root; + } + return document.body; + } + + _resolveViewUrl(viewPath) { + if (/^(https?:)?\/\//.test(viewPath) || viewPath.startsWith('/')) { + return viewPath; + } + if (this.ctx && this.ctx.baseUrl) { + const baseUrl = this.ctx.baseUrl; + const absoluteBase = /^(https?:)?\/\//.test(baseUrl) + ? baseUrl + : `${window.location.origin}/${String(baseUrl).replace(/^\/+/, '')}`; + return new URL(viewPath, absoluteBase).toString(); + } + return new URL(viewPath, import.meta.url).toString(); + } + + _bindAll() { + this._bindVars(); + this._bindEvents(); + } + + _bindVars() { + const bindings = this._normalizeBindings(this.config.bindVars); + for (const [selector, spec] of Object.entries(bindings)) { + const el = this._query(selector); + if (!el) continue; + + const normalized = this._normalizeVarSpec(spec); + const { key, attr, event, parser, formatter } = normalized; + + const applyValue = () => { + const value = formatter ? formatter(this._state[key]) : this._state[key]; + if (attr === 'checked') { + el.checked = !!value; + } else if (attr in el) { + el[attr] = value ?? ''; + } else { + el.setAttribute(attr, value ?? ''); + } + }; + + const onInput = (e) => { + let value; + if (attr === 'checked') { + value = e.target.checked; + } else if (attr in e.target) { + value = e.target[attr]; + } else { + value = e.target.getAttribute(attr); + } + this._state[key] = parser ? parser(value) : value; + }; + + applyValue(); + el.addEventListener(event, onInput); + this._bindings.push(() => el.removeEventListener(event, onInput)); + } + } + + _bindEvents() { + const bindings = this._normalizeBindings(this.config.bindEvents); + for (const [selector, events] of Object.entries(bindings)) { + const el = this._query(selector); + if (!el) continue; + + for (const [eventName, handler] of Object.entries(events)) { + const fn = typeof handler === 'function' + ? handler.bind(this) + : (this[handler] ? this[handler].bind(this) : null); + if (!fn) continue; + el.addEventListener(eventName, fn); + this._bindings.push(() => el.removeEventListener(eventName, fn)); + } + } + } + + _applyStateToBindings() { + const bindings = this._normalizeBindings(this.config.bindVars); + for (const [selector, spec] of Object.entries(bindings)) { + const el = this._query(selector); + if (!el) continue; + const normalized = this._normalizeVarSpec(spec); + const { key, attr, formatter } = normalized; + const value = formatter ? formatter(this._state[key]) : this._state[key]; + if (attr === 'checked') { + el.checked = !!value; + } else if (attr in el) { + el[attr] = value ?? ''; + } else { + el.setAttribute(attr, value ?? ''); + } + } + } + + _normalizeVarSpec(spec) { + if (typeof spec === 'string') { + return { + key: spec, + attr: 'value', + event: 'input', + parser: null, + formatter: null, + }; + } + const attr = spec.attr || (spec.type === 'checkbox' ? 'checked' : 'value'); + const event = spec.event || (attr === 'checked' ? 'change' : 'input'); + return { + key: spec.key, + attr, + event, + parser: spec.parser || null, + formatter: spec.formatter || null, + }; + } + + _normalizeBindings(bindings) { + if (!bindings) return {}; + if (Array.isArray(bindings)) { + const out = {}; + for (const pair of bindings) { + if (pair && typeof pair.selector === 'string') { + out[pair.selector] = pair.value; + } + } + return out; + } + if (bindings && typeof bindings === 'object') { + return bindings; + } + return {}; + } + + _query(selector) { + if (!selector) return null; + if (this.viewEl) { + return this.viewEl.querySelector(selector); + } + return document.querySelector(selector); + } + + _unbindAll() { + for (const unbind of this._bindings) { + try { unbind(); } catch (_) { /* noop */ } + } + this._bindings = []; + } +} + +export class ModuleBuilder { + constructor() { + this._config = { + name: '', + view: '', + rootSelector: '', + wrapperClass: '', + strict: false, + requiredMethods: [], + bindVars: {}, + bindEvents: {}, + state: {}, + }; + } + + name(value) { + this._config.name = value; + return this; + } + + view(path) { + this._config.view = path; + return this; + } + + root(selector) { + this._config.rootSelector = selector; + return this; + } + + wrapperClass(name) { + this._config.wrapperClass = name; + return this; + } + + strict(flag = true) { + this._config.strict = !!flag; + return this; + } + + required(methods = []) { + this._config.requiredMethods = Array.isArray(methods) ? methods : []; + return this; + } + + state(initialState = {}) { + this._config.state = { ...initialState }; + return this; + } + + bindVar(map = {}) { + this._config.bindVars = this._mergeBindings(this._config.bindVars, map); + return this; + } + + bindEvent(map = {}) { + this._config.bindEvents = this._mergeBindings(this._config.bindEvents, map); + return this; + } + + build() { + if (!this._config.name) { + this._config.name = 'Module'; + } + return { ...this._config }; + } + + _mergeBindings(current, next) { + const base = Array.isArray(current) ? this._pairsToObject(current) : { ...(current || {}) }; + if (Array.isArray(next)) { + return { ...base, ...this._pairsToObject(next) }; + } + if (next && typeof next === 'object') { + return { ...base, ...next }; + } + return base; + } + + _pairsToObject(pairs) { + const out = {}; + for (const pair of pairs) { + if (pair && typeof pair.selector === 'string') { + out[pair.selector] = pair.value; + } + } + return out; + } +} + +export default ModuleBuilder; + +export class BindingPair { + constructor(selector, value) { + if (!selector || typeof selector !== 'string') { + throw new Error('[BindingPair] selector must be a string.'); + } + this.selector = selector; + this.value = value; + } +} diff --git a/SL/module/TableModule.js b/SL/module/TableModule.js new file mode 100644 index 0000000..08ee076 --- /dev/null +++ b/SL/module/TableModule.js @@ -0,0 +1,22 @@ +import { Module, ModuleBuilder } from './Module.js'; +import { bindTableEvents } from '../../ui/table-bindings.js'; + +const builder = new ModuleBuilder() + .name('TableModule') + .view('assets/amily-data-table/Memorisation-forms.html') + .strict(true) + .required(['mount']); + +export default class TableModule extends Module { + constructor() { + super(builder); + } + + async mount() { + if (this.el) { + this.el.id = 'amily2_memorisation_forms_panel'; + this.el.style.display = 'none'; + } + bindTableEvents(); + } +}