diff --git a/.gitignore b/.gitignore index 96edeb38..9e9b141e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ !/modules/web-worker-manager /plugins/** +!/plugins/analyze +!/plugins/analyze/** !/plugins/annotations !/plugins/custom-pages !/plugins/empaia diff --git a/plugins/analyze/README.md b/plugins/analyze/README.md new file mode 100644 index 00000000..f1abedf0 --- /dev/null +++ b/plugins/analyze/README.md @@ -0,0 +1,24 @@ + +Analyze plugin +======================== + +Purpose +------- +Adds an "Analyze" tab to the AppBar with two actions: a "Run Recent →" anchor that opens a right-side recent-jobs panel, and "Create New App" which creates floating window with new app form. + +Files +----- +- `analyzeDropdown.mjs` - registers the tab and wires dropdown items. +- `newAppForm.mjs` - the floating form used by "Create New App". + +How to use +---------- +- Provide recent jobs by passing `params.recentJobs` or saving `recentJobs` via plugin options. +- Handle job clicks by implementing `onJobClick({ index, label })` on the plugin instance. +- Provide `params.onCreate` to receive form submission data from `NewAppForm`. + +Implementation notes +-------------------- +- UI behaviors (menu, positioning, hover) are implemented in `SidePanel` (`setMenu`, `showNear`, `scheduleHide`, `cancelHide`) — reuse it for other flyouts. +- `SidePanel.hide()` currently removes the element; consider switching to `display:none` if you need faster show/hide cycles. + diff --git a/plugins/analyze/analyzeDropdown.mjs b/plugins/analyze/analyzeDropdown.mjs new file mode 100644 index 00000000..cc8741fa --- /dev/null +++ b/plugins/analyze/analyzeDropdown.mjs @@ -0,0 +1,459 @@ +import { Dropdown } from "../../ui/classes/elements/dropdown.mjs"; +import { NewAppForm } from "./newAppForm.mjs"; +import { SidePanel } from "../../ui/classes/components/sidePanel.mjs"; + +addPlugin('analyze', class extends XOpatPlugin { + constructor(id, params) { + super(id); + this.params = params || {}; + // plugin-level stored recent jobs can be configured via params or saved options + this.recentJobs = this.getOption('recentJobs') || this.params.recentJobs || []; + } + + pluginReady() { + const register = () => { + + if (!window.USER_INTERFACE || !USER_INTERFACE.AppBar || !USER_INTERFACE.AppBar.menu) { + + // retry shortly if AppBar not ready yet + return setTimeout(register, 50); + } + + // safe translation helper: return translated value or fallback when missing + const tOr = (key, fallback) => { + if (typeof $?.t === 'function') { + try { + const translated = $.t(key); + if (translated && translated !== key) return translated; + } catch (e) { /* ignore and fallback */ } + } + return fallback; + }; + + const title = tOr('analyze.title', 'Analyze'); + const tab = USER_INTERFACE.AppBar.addTab( + this.id, // ownerPluginId + title, // title (localized if available) + 'fa-magnifying-glass', // icon + [], // body + Dropdown // itemClass so Menu constructs plugin component + ); + + + if (tab) { + const attachToggle = () => { + try { + const btnId = `${tab.parentId}-b-${tab.id}`; + const btnEl = document.getElementById(btnId); + if (!btnEl) return false; + let wrapper = btnEl.closest('.dropdown'); + if (!wrapper) { + try { + const newWrapper = tab.create(); + const parent = btnEl.parentElement; + if (parent) { + parent.insertBefore(newWrapper, btnEl); + btnEl.remove(); + wrapper = newWrapper; + } + } catch (e) { + } + } + + if (wrapper) { + const trigger = wrapper.querySelector('[tabindex]') || wrapper; + trigger.addEventListener('click', (e) => { + try { + wrapper.classList.toggle('dropdown-open'); + if (!wrapper.classList.contains('dropdown-open')) { + try { tab.hideRecent?.(); } catch(_) {} + } + } catch(_) {} + e.stopPropagation(); + }); + return true; + } + } catch (e) { console.error('[analyze] attachToggle error', e); } + return false; + }; + // Try immediate attach; if DOM not present yet, retry shortly + if (!attachToggle()) setTimeout(attachToggle, 50); + } + + // Configure dropdown items using the Dropdown API + try { + if (tab && typeof tab.addItem === 'function') { + // create the 'recent' section but keep its title empty so no uppercase header is shown + try { tab.addSection({ id: 'recent', title: '' }); } catch (e) {} + // prefer a slightly wider dropdown to match previous styling + try { if (tab) { tab.widthClass = 'w-64'; if (tab._contentEl) tab._contentEl.classList.add('w-64'); } } catch(e) {} + + // Only add a single anchor item for Run Recent; the detailed list appears in the SidePanel on hover + tab.addItem({ + id: 'run-recent', + section: 'recent', + label: tOr('analyze.runRecent', 'Run Recent') + ' \u2192', + onClick: () => false, + }); + + // create a reusable SidePanel and attach delegated hover handlers to show it + try { + // let the panel size to its content by default (width: 'auto') + const side = new SidePanel({ id: `${this.id}-recent-panel`, width: 'auto', maxHeight: '70vh' }); + const attachHover = () => { + try { + const content = tab._contentEl; + if (!content) return false; + + // delegate to the 'run-recent' item inside the dropdown content + content.addEventListener('mouseover', (e) => { + const hit = e.target.closest && e.target.closest('[data-item-id]'); + if (hit && hit.dataset && hit.dataset.itemId === 'run-recent') { + try { + // cancel any pending hide so we can reopen immediately + side.cancelHide?.(); + const jobs = (this.recentJobs && this.recentJobs.length) ? this.recentJobs : ['Recent Job 1','Recent Job 2','Recent Job 3']; + // use SidePanel helper to build a menu and position the panel next to the anchor + side.setMenu(jobs, (it, idx) => { + try { if (typeof this.onJobClick === 'function') this.onJobClick({ index: idx, label: (typeof it === 'string' ? it : (it && it.label)) }); } catch(_){} + }); + side.showNear(hit, { nudge: 1 }); + try { tab.hideRecent = () => side.hide(); } catch(_) {} + } catch (err) { console.error('[analyze] show side panel error', err); } + } + }); + + content.addEventListener('mouseout', (e) => { + const related = e.relatedTarget; + if (!related || !related.closest || !related.closest(`#${side.id}`)) side.scheduleHide(); + }); + return true; + } catch (e) { console.error('[analyze] attachHover error', e); } + return false; + }; + if (!attachHover()) setTimeout(attachHover, 50); + } catch (e) { /* ignore */ } + + + + tab.addItem({ + id: 'create-app', + label: tOr('analyze.createApp', 'Create New App'), + onClick: () => { + try { + const form = new NewAppForm({ onSubmit: (data) => { + try { + if (this.params.onCreate?.(data) !== false) { + USER_INTERFACE.Dialogs.show('Successfuly created new app'); + } + } + catch (err) { console.error(err); } + }}); + const win = form.showFloating({ title: tOr('analyze.createApp', 'Create New App'), width: 420, height: 360 }); + if (!win) { + const overlayId = `${this.id}-newapp-overlay`; + USER_INTERFACE.Dialogs.showCustom(overlayId, 'New App', `
`, '', { allowClose: true }); + const container = document.getElementById(overlayId)?.querySelector('.card-body'); + if (container) form.attachTo(container); + } + } catch (e) { console.error('[analyze] create-app error', e); } + return false; + } + }); + + // Add Apps item: collapses dropdown and opens floating window listing apps + tab.addItem({ + id: 'apps-list', + label: tOr('analyze.apps', 'Apps'), + onClick: async () => { + try { + this._collapseDropdown(tab); + await this._showAppsWindow(tOr); + } catch (e) { + console.error('[analyze] apps-list error', e); + } + return false; + } + }); + } + } catch (e) { console.warn('[analyze] failed to configure dropdown items', e); } + // Close dropdowns when clicking away: attach a document-level click handler once per tab + const attachDocumentCloser = (t) => { + try { + if (!t || t.__analyzeDocCloserAttached) return; + const btnId = `${t.parentId}-b-${t.id}`; + const docHandler = (ev) => { + try { + const openWrappers = Array.from(document.querySelectorAll('.dropdown.dropdown-open')); + openWrappers.forEach((wrapper) => { + const btnEl = document.getElementById(btnId); + if (btnEl && (btnEl === ev.target || btnEl.contains(ev.target))) return; + try { wrapper.classList.remove('dropdown-open'); } catch(_) {} + }); + try { t.hideRecent?.(); } catch(_) {} + } catch (_) { /* swallow */ } + }; + document.addEventListener('click', docHandler, true); + const keyHandler = (ev) => { if (ev.key === 'Escape') { try { Array.from(document.querySelectorAll('.dropdown.dropdown-open')).forEach(w=>w.classList.remove('dropdown-open')); try { t.hideRecent?.(); } catch(_){} } catch(_){} } }; + document.addEventListener('keydown', keyHandler, true); + t.__analyzeDocCloserAttached = true; + } catch (e) { /* ignore */ } + }; + try { attachDocumentCloser(tab); } catch(e) { /* ignore */ } + }; + + register(); + } + + // Hardcoded case ID for now - should be made configurable + get _caseId() { + return '87fbb59a-3183-4d36-ab22-48f4e027d1f0'; + } + + _collapseDropdown(tab) { + try { + const btnId = `${tab.parentId}-b-${tab.id}`; + const btnEl = document.getElementById(btnId); + const wrapper = btnEl?.closest('.dropdown'); + wrapper?.classList.remove('dropdown-open'); + try { tab.hideRecent?.(); } catch(_) {} + } catch(_) {} + } + + async _showAppsWindow(tOr) { + let items = []; + try { + const resp = await window.EmpaiaStandaloneJobs?.getApps?.(); + items = Array.isArray(resp?.items) ? resp.items : []; + } catch (e) { + console.warn('[analyze] failed to fetch apps, showing empty list', e); + } + + const { FloatingWindow } = await import('../../ui/classes/components/floatingWindow.mjs'); + const fw = new FloatingWindow({ + id: `${this.id}-apps-window`, + title: tOr('analyze.apps', 'Apps'), + width: 520, + height: 480 + }); + fw.attachTo(document.body); + + const container = document.createElement('div'); + container.className = 'p-2 space-y-3'; + + for (const [idx, app] of items.entries()) { + const card = this._createAppCard(app, idx, tOr); + container.appendChild(card); + } + + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'p-2 text-sm opacity-70'; + empty.textContent = tOr('analyze.noApps', 'No apps available.'); + container.appendChild(empty); + } + + fw.setBody(container); + fw.focus(); + } + + _createAppCard(app, idx, tOr) { + const appId = app?.id || app?.app_id; + const wrap = document.createElement('div'); + wrap.className = 'p-3 rounded-box bg-base-200 border border-base-300'; + + // Header with title and configure button + const header = document.createElement('div'); + header.className = 'flex items-center justify-between'; + + const title = document.createElement('span'); + title.className = 'font-medium'; + title.textContent = app?.name_short || app?.name || `App ${idx + 1}`; + header.appendChild(title); + + const configBtn = document.createElement('button'); + configBtn.type = 'button'; + configBtn.className = 'btn btn-xs btn-ghost'; + configBtn.textContent = 'Configure'; + header.appendChild(configBtn); + wrap.appendChild(header); + + // Description + if (app?.store_description) { + const desc = document.createElement('div'); + desc.className = 'text-xs opacity-70 mt-1'; + desc.textContent = app.store_description; + wrap.appendChild(desc); + } + + // Inputs section (hidden by default) + const inputsSection = document.createElement('div'); + inputsSection.className = 'mt-2 hidden'; + inputsSection.innerHTML = '
Loading inputs...
'; + wrap.appendChild(inputsSection); + + let inputsForm = null; + let inputsLoaded = false; + + configBtn.addEventListener('click', async () => { + inputsSection.classList.toggle('hidden'); + if (!inputsLoaded && !inputsSection.classList.contains('hidden')) { + inputsLoaded = true; + try { + const api = EmpationAPI.V3.get(); + const examination = await api.examinations.create(this._caseId, appId); + const scope = await api.getScopeFrom(examination); + inputsForm = await this._buildInputsForm(appId, scope); + inputsSection.innerHTML = ''; + inputsSection.appendChild(inputsForm.container); + } catch (e) { + inputsSection.innerHTML = `
Failed to load inputs: ${e.message}
`; + } + } + }); + + // Actions row + const actions = document.createElement('div'); + actions.className = 'flex items-center gap-2 mt-2'; + + const runBtn = document.createElement('button'); + runBtn.type = 'button'; + runBtn.className = 'btn btn-sm btn-primary'; + runBtn.textContent = tOr('analyze.run', 'Run'); + + const status = document.createElement('span'); + status.className = 'text-xs flex-1'; + status.textContent = tOr('analyze.jobReady', 'Ready'); + + runBtn.addEventListener('click', async () => { + try { + runBtn.disabled = true; + status.textContent = tOr('analyze.jobStarting', 'Starting...'); + + const inputs = inputsForm?.getInputs?.() || {}; + console.log('[analyze] Running job with inputs:', inputs); + + const res = await window.EmpaiaStandaloneJobs?.createAndRunJob?.({ + appId, + caseId: this._caseId, + mode: 'STANDALONE', + inputs + }); + + const isSuccess = res?.status === 'COMPLETED'; + status.textContent = `${tOr('analyze.jobFinal', 'Status')}: ${res?.status || 'UNKNOWN'}`; + status.className = isSuccess ? 'text-xs flex-1 text-success' : 'text-xs flex-1 text-error'; + console.log('[analyze] Job final:', res); + } catch (err) { + console.error('[analyze] Failed to run app job', err); + status.textContent = `Error: ${err?.message || err}`; + status.className = 'text-xs flex-1 text-error'; + } finally { + runBtn.disabled = false; + } + }); + + actions.appendChild(runBtn); + actions.appendChild(status); + wrap.appendChild(actions); + + return wrap; + } + + async _buildInputsForm(appId, scope, mode = 'STANDALONE') { + const container = document.createElement('div'); + container.className = 'space-y-2 mt-2'; + + try { + const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId, scope); + if (!ead) { + container.innerHTML = '
No EAD available
'; + return { container, getInputs: () => ({}) }; + } + + const requiredInputs = window.EmpaiaStandaloneJobs?.getRequiredInputs?.(ead, mode) || []; + if (requiredInputs.length === 0) { + container.innerHTML = '
No inputs required
'; + return { container, getInputs: () => ({}) }; + } + + let slides = []; + try { + slides = await window.EmpaiaStandaloneJobs?.getCaseSlides?.(this._caseId) || []; + } catch (e) { + console.warn('[analyze] Failed to fetch slides', e); + } + + const inputFields = {}; + + for (const input of requiredInputs) { + const row = this._createInputRow(input, slides, inputFields); + container.appendChild(row); + } + + const getInputs = () => { + const result = {}; + for (const [key, el] of Object.entries(inputFields)) { + if (el.type === 'checkbox') { + result[key] = el.checked ? 'true' : 'false'; + } else { + const val = el.value?.trim(); + if (val) result[key] = val; + } + } + return result; + }; + + return { container, getInputs }; + } catch (e) { + console.error('[analyze] Failed to build inputs form', e); + container.innerHTML = `
Error: ${e.message}
`; + return { container, getInputs: () => ({}) }; + } + } + + _createInputRow(input, slides, inputFields) { + const row = document.createElement('div'); + row.className = 'flex items-center gap-2'; + + const label = document.createElement('label'); + label.className = 'text-xs font-medium min-w-20'; + label.textContent = `${input.key} (${input.type})`; + row.appendChild(label); + + let fieldEl; + + if (input.type === 'wsi') { + fieldEl = document.createElement('select'); + fieldEl.className = 'select select-xs select-bordered flex-1'; + fieldEl.innerHTML = ''; + slides.forEach(slide => { + const opt = document.createElement('option'); + opt.value = slide.id; + opt.textContent = slide.local_id || slide.id; + fieldEl.appendChild(opt); + }); + } else if (input.type === 'bool') { + fieldEl = document.createElement('input'); + fieldEl.type = 'checkbox'; + fieldEl.className = 'checkbox checkbox-xs'; + } else if (input.type === 'integer' || input.type === 'float') { + fieldEl = document.createElement('input'); + fieldEl.type = 'number'; + fieldEl.className = 'input input-xs input-bordered flex-1'; + if (input.type === 'float') fieldEl.step = 'any'; + } else { + fieldEl = document.createElement('input'); + fieldEl.type = 'text'; + fieldEl.className = 'input input-xs input-bordered flex-1'; + if (!['string'].includes(input.type)) { + fieldEl.placeholder = `${input.type} ID`; + } + } + + inputFields[input.key] = fieldEl; + row.appendChild(fieldEl); + + return row; + } +}); \ No newline at end of file diff --git a/plugins/analyze/include.json b/plugins/analyze/include.json new file mode 100644 index 00000000..48d177df --- /dev/null +++ b/plugins/analyze/include.json @@ -0,0 +1,10 @@ +{ + "id": "analyze", + "name": "Analyze dropdown in Main Menu", + "author": "Filip Vrubel", + "version": "1.0.0", + "description": "Plugin for creating and running jobs", + "icon": null, + "includes" : ["newAppForm.mjs", "analyzeDropdown.mjs"], + "permaLoad": true +} \ No newline at end of file diff --git a/plugins/analyze/newAppForm.mjs b/plugins/analyze/newAppForm.mjs new file mode 100644 index 00000000..a0b7cd4c --- /dev/null +++ b/plugins/analyze/newAppForm.mjs @@ -0,0 +1,194 @@ +import van from "../../ui/vanjs.mjs"; +import { BaseComponent } from "../../ui/classes/baseComponent.mjs"; +import { FloatingWindow } from "../../ui/classes/components/floatingWindow.mjs"; + +const { div, input, textarea, span, button, label, h3 } = van.tags; + +class NewAppForm extends BaseComponent { + constructor(options = {}) { + options = super(options).options; + this.id = options.id || 'new-app'; + this.onSubmit = options.onSubmit || (() => {}); + this.values = options.values || {}; + this._el = null; + this._fields = {}; + this._floatingWindow = null; + } + + _row(labelText, fieldNode) { + return div({ class: 'mb-2' }, + label({ class: 'block mb-1 text-xs font-medium' }, labelText), + fieldNode + ); + } + + create() { + if (this._el) return this._el; + + // use explicit inline sizing so styles apply even if utility classes are missing + const smallInputStyle = 'height:30px;padding:4px 6px;font-size:12px;line-height:1.1;box-sizing:border-box;'; + const smallTextareaBase = 'padding:6px 6px;font-size:12px;line-height:1.2;box-sizing:border-box;resize:vertical;'; + const schemaEl = input({ type: 'text', id: this.id + '-schema', class: 'input w-full', style: smallInputStyle, value: this.values.schema || '' }); + const nameEl = input({ type: 'text', id: this.id + '-name', class: 'input w-full', style: smallInputStyle, value: this.values.name || '' }); + const nsEl = input({ type: 'text', id: this.id + '-namespace', class: 'input w-full', style: smallInputStyle, value: this.values.namespace || '' }); + const descEl = textarea({ id: this.id + '-description', class: 'textarea w-full', style: smallTextareaBase + 'height:64px;', rows: 4 }, this.values.description || ''); + const inputsEl = textarea({ id: this.id + '-inputs', class: 'textarea w-full', style: smallTextareaBase + 'height:48px;', rows: 3 }, this.values.inputs || ''); + const outputsEl = textarea({ id: this.id + '-outputs', class: 'textarea w-full', style: smallTextareaBase + 'height:48px;', rows: 3 }, this.values.outputs || ''); + const jobEl = input({ type: 'text', id: this.id + '-joburl', class: 'input w-full', style: smallInputStyle, value: this.values.jobUrl || '' }); + + const btnEdit = button({ class: 'btn btn-secondary btn-sm mr-2', type: 'button', onclick: (ev) => this._onEdit() }, 'Edit EAD'); + const btnCreate = button({ class: 'btn btn-primary btn-sm', type: 'button', onclick: (ev) => this._onCreate() }, 'Create'); + + // remove inner title and close button to rely on the FloatingWindow header + // increase top padding so the FloatingWindow title has breathing room + const form = div({ class: 'p-4 bg-base-200 border border-base-300 rounded-md max-w-full relative', style: 'max-width:420px;width:100%;' }, + this._row('Schema:', schemaEl), + this._row('Name:', nameEl), + this._row('Namespace:', nsEl), + this._row('Description:', descEl), + this._row('Inputs:', inputsEl), + this._row('Outputs:', outputsEl), + this._row('Job URL:', jobEl), + div({ class: 'mt-4 flex gap-2 justify-end' }, btnEdit, btnCreate) + ); + + this._fields = { + schema: schemaEl, + name: nameEl, + namespace: nsEl, + description: descEl, + inputs: inputsEl, + outputs: outputsEl, + jobUrl: jobEl + }; + + // make the form fill available height inside a FloatingWindow and layout vertically + try { + this._el = form; + // allow flex layout to let textareas expand/shrink with window + this._el.style.display = 'flex'; + this._el.style.flexDirection = 'column'; + this._el.style.height = '100%'; + + // ensure textareas grow and shrink with the container (min-height:0 prevents overflow) + const makeFlexTA = (el) => { + if (!el) return; + el.style.flex = '1 1 auto'; + el.style.minHeight = '0'; + // keep existing padding/box sizing + }; + makeFlexTA(this._fields.description); + makeFlexTA(this._fields.inputs); + makeFlexTA(this._fields.outputs); + + // push buttons to bottom + const btnContainer = this._el.querySelector('.mt-4'); + if (btnContainer) btnContainer.style.marginTop = 'auto'; + } catch (e) { + // non-fatal: fall back to previous behavior + this._el = form; + } + + return this._el; + } + + /** + * Close helper: prefer closing a floating window if we opened one; + * otherwise close the parent Dialog via Dialogs.closeWindow if present, + * fallback to removing the element from DOM (legacy behavior). + */ + _close() { + try { + if (this._floatingWindow) { + try { this._floatingWindow.close(); } catch(_) {} + this._floatingWindow = null; + return; + } + // if inside a Dialog created via Dialogs.showCustom, find the dialog root + let root = this._el && this._el.closest ? this._el.closest('[data-dialog="true"]') : null; + if (root && root.id && window.USER_INTERFACE && USER_INTERFACE.Dialogs && typeof USER_INTERFACE.Dialogs.closeWindow === 'function') { + try { USER_INTERFACE.Dialogs.closeWindow(root.id); return; } catch (_) {} + } + // fallback: traditional removal of the attached wrapper + const el = this._el; + if (!el) return; + const wrapper = el.parentNode; + if (wrapper && wrapper.parentNode) { + wrapper.parentNode.removeChild(wrapper); + } else if (el.parentNode) { + el.parentNode.removeChild(el); + } + } catch (e) { console.error('NewAppForm _close error', e); } + } + + _serialize() { + const f = this._fields; + const getVal = (el) => (el && el.value !== undefined) ? el.value : (el && el.textContent) || ''; + return { + schema: getVal(f.schema), + name: getVal(f.name), + namespace: getVal(f.namespace), + description: getVal(f.description), + inputs: getVal(f.inputs), + outputs: getVal(f.outputs), + jobUrl: getVal(f.jobUrl), + }; + } + + _onEdit() { + } + + _onCreate() { + const data = this._serialize(); + try { + const r = this.onSubmit(data); + if (r !== false) { + try { if (this._floatingWindow) { this._floatingWindow.close(); this._floatingWindow = null; } } catch(_) {} + } + } catch (e) { + console.error('NewAppForm onSubmit error', e); + } + } + + /** + * Show this form inside a FloatingWindow. Returns the FloatingWindow instance. + * Options may include width/height/title. + */ + showFloating(opts = {}) { + try { + if (this._floatingWindow) return this._floatingWindow; + const id = this.id + '-window'; + // compute centered start position when possible + const width = opts.width || 420; + // increase default height so the form fits comfortably; still allows scrolling + const height = opts.height || 520; + const startLeft = (typeof window !== 'undefined') ? Math.max(8, Math.round((window.innerWidth - width) / 2)) : (opts.startLeft || 64); + const startTop = (typeof window !== 'undefined') ? Math.max(8, Math.round((window.innerHeight - height) / 2)) : (opts.startTop || 64); + const w = new FloatingWindow({ id, title: opts.title || 'New App', width, height, startLeft, startTop, onClose: () => { this._floatingWindow = null; } }, ); + // attach window to body so it is visible + w.attachTo(document.body); + // wrap the form in a scrollable card-body so FloatingWindow keeps expected layout + const wrapper = document.createElement('div'); + wrapper.className = 'card-body p-3 gap-2 overflow-auto'; + wrapper.style.height = '100%'; + wrapper.appendChild(this.create()); + // set the body to our wrapper node + w.setBody(wrapper); + this._floatingWindow = w; + return w; + } catch (e) { console.error('NewAppForm showFloating error', e); } + return null; + } + + attachTo(parent) { + const target = (typeof parent === 'string') ? document.getElementById(parent) : parent; + if (!target) throw new Error('attachTo: parent not found'); + target.appendChild(this.create()); + } + + getValues() { + return this._serialize(); + } +} + +export { NewAppForm }; diff --git a/ui/classes/baseComponent.mjs b/ui/classes/baseComponent.mjs index 799ac90d..58d759ea 100644 --- a/ui/classes/baseComponent.mjs +++ b/ui/classes/baseComponent.mjs @@ -96,14 +96,19 @@ export class BaseComponent { } /** - * * @param {*} element - The element to attach the component to + * @return {BaseComponent} builder pattern */ attachTo(element) { this.refreshClassState(); this.refreshPropertiesState(); - if (element instanceof BaseComponent) { + // Accept true BaseComponent instances and component-like objects + const isComponentLike = (el) => { + return !!el && (el instanceof BaseComponent || (typeof el === "object" && typeof el.create === "function" && typeof el.id === "string")); + }; + + if (isComponentLike(element)) { const mount = document.getElementById(element.id); if (mount === null) { element._children.push(this); @@ -111,22 +116,47 @@ export class BaseComponent { mount.append(this.create()); } } else { - const mount = typeof element === "string" + // Resolve mount target from id/string or direct reference + let mount = typeof element === "string" ? document.getElementById(element) : element; + // If we got a jQuery/Cash wrapper, unwrap to the first DOM node + // (supports libraries exposing .jquery flag or .get/.[0]) + if (mount && (mount.jquery || typeof mount.get === "function" || Array.isArray(mount))) { + const candidate = typeof mount.get === "function" ? mount.get(0) : (Array.isArray(mount) ? mount[0] : mount[0]); + if (candidate) mount = candidate; + } + if (!mount) { console.error(`Element ${element} not found`); - van.add(element, this.create()); - } else { + try { + van.add(element, this.create()); + } catch (_) { /* noop: element may be invalid for van.add */ } + } else if (typeof mount.append === "function") { mount.append(this.create()); + } else if (mount.nodeType || mount instanceof Node) { + // Fallback for very old environments where append may be missing + const created = this.create(); + if (mount.appendChild) mount.appendChild(created); + else { + try { mount.innerHTML += created.outerHTML || String(created); } catch (_) {} + } + } else { + // Last resort: try jQuery-style append if available or log a clearer error + try { mount.append(this.create()); } + catch (e) { + console.error("Failed to attach component: unsupported mount target", mount); + console.error(e); + } } } + return this; } /** - * * @param {*} element - The element to prepend the component to + * @return {BaseComponent} builder pattern */ prependedTo(element) { this.refreshClassState(); @@ -151,6 +181,7 @@ export class BaseComponent { mount.prepend(this.create()); } } + return this; } /** @@ -267,6 +298,22 @@ export class BaseComponent { this.classState.val = Object.values(this.classMap).join(" "); } + /** + * Toggle the class of the component + * @param {string} key + * @param {string} value + * @param {boolean} on if true, set class + */ + toggleClass(key, value, on=true) { + this.classMap[key] = on ? value : ""; + this.classState.val = Object.values(this.classMap).join(" "); + } + + /** + * Set attribute property to the element + * @param {string} key attribute name + * @param {string} value + */ setExtraProperty(key, value) { this.propertiesMap[key] = value; let stateMap = this.propertiesStateMap[key]; @@ -409,3 +456,18 @@ export class BaseComponent { this.refreshPropertiesState(); } } + +/** + * @typedef {BaseUIOptions} SelectableUIOptions + * @property {string} [itemID] - The selection ID + */ +export class BaseSelectableComponent extends BaseComponent { + constructor(options, ...args) { + options = super(options, ...args); + this.itemID = options.itemID || this.id; + } + + setSelected(itemID) { + throw new Error("Component must override setSelected method"); + } +} diff --git a/ui/classes/components/sidePanel.mjs b/ui/classes/components/sidePanel.mjs new file mode 100644 index 00000000..5e06b1d3 --- /dev/null +++ b/ui/classes/components/sidePanel.mjs @@ -0,0 +1,155 @@ +import { BaseComponent } from "../baseComponent.mjs"; + +/** + * SidePanel: small reusable fixed-position panel that plugins can show near an anchor. + * Simple API: constructor(options), attachToBody(), setBody(node|string), showAt({left,top}), hide(), remove(). + */ +class SidePanel extends BaseComponent { + constructor(options = {}) { + options = super(options).options; + this.id = options.id || `side-panel-${Math.random().toString(36).slice(2,8)}`; + // width can be a number (px) or 'auto' + this.width = (options.width === undefined) ? 'auto' : options.width; + this.minWidth = options.minWidth || 120; + this.maxWidth = options.maxWidth || 420; + this.maxHeight = options.maxHeight || '70vh'; + this._el = null; + this._hideTimer = null; + this.hoverDelay = options.hoverDelay || 250; + } + + create() { + if (this._el) return this._el; + const el = document.createElement('div'); + el.id = this.id; + el.className = ['dropdown-content','bg-base-200','text-base-content','rounded-box','shadow-xl','border','border-base-300'].join(' '); + el.style.position = 'fixed'; + // let the panel size to its content by default, but constrain widths + if (typeof this.width === 'number') el.style.width = `${this.width}px`; + else el.style.width = 'auto'; + el.style.minWidth = `${this.minWidth}px`; + el.style.maxWidth = (typeof this.maxWidth === 'number') ? `${this.maxWidth}px` : this.maxWidth; + el.style.maxHeight = this.maxHeight; + el.style.overflow = 'auto'; + el.style.zIndex = '9999'; + // ensure it doesn't capture pointer events unexpectedly + return (this._el = el); + } + + attachToBody() { + const el = this.create(); + if (!document.body.contains(el)) document.body.appendChild(el); + } + + // convenience: build a simple vertical menu from an array of labels or objects + // items: array of strings or { label, value } + // onClick: function(item, index) + setMenu(items = [], onClick) { + const node = document.createElement('div'); + node.className = 'p-2'; + const ul = document.createElement('ul'); ul.className = 'menu bg-transparent p-0'; ul.setAttribute('role','menu'); + for (let i = 0; i < items.length; i++) { + const it = items[i]; + const label = (typeof it === 'string') ? it : (it && it.label) || String(it); + const li = document.createElement('li'); li.setAttribute('role','none'); + const a = document.createElement('a'); + a.className = 'flex items-center gap-3 rounded-md px-3 py-2 hover:bg-base-300 focus:bg-base-300'; + a.setAttribute('role','menuitem'); a.setAttribute('tabindex','-1'); + a.textContent = label; + // capture index/value for the click handler + a.addEventListener('click', (ev) => { + try { ev.stopPropagation(); if (typeof onClick === 'function') onClick(it, i); } catch(_){} + }); + li.appendChild(a); ul.appendChild(li); + } + node.appendChild(ul); + this.setBody(node); + } + + cancelHide() { if (this._hideTimer) { clearTimeout(this._hideTimer); this._hideTimer = null; } } + scheduleHide(delay) { this.cancelHide(); this._hideTimer = setTimeout(()=>{ try{ this.hide(); } catch(_){}; this._hideTimer = null; }, (typeof delay === 'number') ? delay : this.hoverDelay); } + + // Position the panel adjacent to an anchor element or rect and show it. + // anchor: Element or DOMRect-like { left, right, top } + // opts: { nudge: number, offsetY: number } + showNear(anchor, opts = {}) { + try { + const el = this.create(); + this.attachToBody(); + const rect = (anchor && anchor.getBoundingClientRect) ? anchor.getBoundingClientRect() : (anchor || { left: 0, right: 0, top: 0 }); + const nudge = (opts.nudge === undefined) ? 1 : opts.nudge; + const offsetY = opts.offsetY || 0; + requestAnimationFrame(() => { + try { + const panelEl = document.getElementById(this.id); + const panelW = panelEl && panelEl.offsetWidth ? panelEl.offsetWidth : (typeof this.width === 'number' ? this.width : this.minWidth); + const panelH = panelEl && panelEl.offsetHeight ? panelEl.offsetHeight : 0; + let left = rect.right; + if (left + panelW > window.innerWidth - 8) { + left = Math.max(8, rect.left - panelW); + } else { + left = Math.max(8, left - nudge); + } + let top = Math.max(8, rect.top + offsetY); + // clamp vertically so the panel stays within viewport when possible + if (panelH && (top + panelH > window.innerHeight - 8)) { + top = Math.max(8, window.innerHeight - panelH - 8); + } + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.display = ''; + // ensure hover keeps it open + panelEl.addEventListener('mouseenter', () => { this.cancelHide(); }); + panelEl.addEventListener('mouseleave', () => { this.scheduleHide(); }); + } catch (e) { /* swallow layout issues */ } + }); + } catch (e) { /* ignore */ } + } + + setBody(content) { + const el = this.create(); + el.innerHTML = ''; + if (typeof content === 'string') { + el.innerHTML = content; + } else if (content instanceof Node) { + el.appendChild(content); + } else if (content && typeof content.create === 'function') { + el.appendChild(content.create()); + } + // allow layout to settle then ensure width fits content within min/max + try { + requestAnimationFrame(() => { + try { + // if width is 'auto' let the browser size it naturally; enforce min/max via CSS + if (this.width === 'auto') { + el.style.width = 'auto'; + // no further action; CSS min/max will constrain + } else if (typeof this.width === 'number') { + el.style.width = `${this.width}px`; + } + } catch (e) {} + }); + } catch (e) {} + } + + showAt({ left = 0, top = 0 } = {}) { + const el = this.create(); + this.attachToBody(); + el.style.left = `${left}px`; + el.style.top = `${top}px`; + el.style.display = ''; + } + + hide() { + const el = this._el; if (!el) return; + try { el.remove(); } catch(_) { el.style.display = 'none'; } + } + + remove() { + if (!this._el) return; + try { this._el.parentNode && this._el.parentNode.removeChild(this._el); } catch(_) {} + this._el = null; + } +} + +export { SidePanel };