diff --git a/eslint.config.mjs b/eslint.config.mjs index 6fe0c31..b0f4637 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,7 @@ const stagedObsidianRules = { 'obsidianmd/platform': obsidianRuleSeverity, 'obsidianmd/prefer-abstract-input-suggest': obsidianRuleSeverity, 'obsidianmd/prefer-active-doc': obsidianRuleSeverity, + 'obsidianmd/prefer-create-el': obsidianRuleSeverity, 'obsidianmd/prefer-file-manager-trash-file': obsidianRuleSeverity, 'obsidianmd/prefer-get-language': obsidianRuleSeverity, 'obsidianmd/prefer-instanceof': obsidianRuleSeverity, diff --git a/package-lock.json b/package-lock.json index f2d687d..f8e27c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "esbuild": "0.28.1", "eslint": "^10.2.0", "eslint-plugin-jest": "^29.15.1", - "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-obsidianmd": "^0.4.1", "eslint-plugin-simple-import-sort": "^12.1.1", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", @@ -1186,6 +1186,26 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", + "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "ignore": "^7.0.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4675,12 +4695,13 @@ } }, "node_modules/eslint-plugin-obsidianmd": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.3.0.tgz", - "integrity": "sha512-QvGDI6B2nxJBrsZKGTg31da2A/fEJNlnwN+fRZkaoPIu1QL3fYXUdpP7ThyMdr/0iTYQxifb9lt2X9cpydQx1w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.4.1.tgz", + "integrity": "sha512-Nv3593kVsFOS8kir/HWaJ5CR3xcyiPWEPyXst5Pib8mxRYYuLYPpJS2ALMoZXHulHyiGwoYW8AaxvLRc4rhrRg==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/config-helpers": "^0.4.2", "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", @@ -4689,7 +4710,7 @@ "@types/node": "20.12.12", "@typescript-eslint/types": "^8.33.1", "@typescript-eslint/utils": "^8.33.1", - "eslint": ">=9.0.0", + "eslint": ">=9.19.0", "eslint-plugin-depend": "1.3.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-json-schema-validator": "5.1.0", @@ -4710,7 +4731,7 @@ "peerDependencies": { "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", - "eslint": ">=9.0.0", + "eslint": ">=9.19.0", "obsidian": "1.8.7", "typescript-eslint": "^8.35.1" } diff --git a/package.json b/package.json index 5ab8c23..8e3efcd 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "esbuild": "0.28.1", "eslint": "^10.2.0", "eslint-plugin-jest": "^29.15.1", - "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-obsidianmd": "^0.4.1", "eslint-plugin-simple-import-sort": "^12.1.1", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 08c0ffd..86f69b0 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -219,12 +219,9 @@ export class QoderianView extends ItemView { * The wrapper is moved to the active tab's nav row on tab switches. */ private buildNavRowContent(): HTMLElement { - const activeDocument = this.containerEl.ownerDocument; - - const fragment = activeDocument.createDocumentFragment(); + const wrapper = createDiv({ cls: 'qoderian-input-nav-content' }); - this.tabBarContainerEl = activeDocument.createElement('div'); - this.tabBarContainerEl.className = 'qoderian-tab-bar-container'; + this.tabBarContainerEl = wrapper.createDiv({ cls: 'qoderian-tab-bar-container' }); this.tabBar = new TabBar(this.tabBarContainerEl, { onTabClick: (tabId) => this.handleTabClick(tabId), onTabClose: (tabId) => { @@ -234,10 +231,8 @@ export class QoderianView extends ItemView { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }, }); - fragment.appendChild(this.tabBarContainerEl); - const navActionsEl = activeDocument.createElement('div'); - navActionsEl.className = 'qoderian-input-nav-actions'; + const navActionsEl = wrapper.createDiv({ cls: 'qoderian-input-nav-actions' }); this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); setIcon(this.newTabButtonEl, 'square-plus'); @@ -269,11 +264,6 @@ export class QoderianView extends ItemView { this.toggleHistoryDropdown(); }); - fragment.appendChild(navActionsEl); - - const wrapper = activeDocument.createElement('div'); - wrapper.className = 'qoderian-input-nav-content'; - wrapper.appendChild(fragment); return wrapper; } diff --git a/src/features/chat/controllers/conversation-controller.ts b/src/features/chat/controllers/conversation-controller.ts index ffb07db..c48f196 100644 --- a/src/features/chat/controllers/conversation-controller.ts +++ b/src/features/chat/controllers/conversation-controller.ts @@ -659,7 +659,7 @@ export class ConversationController { // Show regenerate button if title generation failed, or loading indicator if pending if (conv.titleGenerationStatus === 'pending') { - const loadingEl = actions.createEl('span', { cls: 'qoderian-action-btn qoderian-action-loading' }); + const loadingEl = actions.createSpan({ cls: 'qoderian-action-btn qoderian-action-loading' }); setIcon(loadingEl, 'loader-2'); loadingEl.setAttribute('aria-label', 'Generating title...'); } else if (conv.titleGenerationStatus === 'failed') { @@ -874,9 +874,7 @@ export class ConversationController { const titleEl = item.querySelector('.qoderian-history-item-title') as HTMLElement; if (!titleEl) return; - const input = (item.ownerDocument ?? window.document).createElement('input'); - input.type = 'text'; - input.className = 'qoderian-rename-input'; + const input = item.createEl('input', { cls: 'qoderian-rename-input', type: 'text' }); input.value = currentTitle; titleEl.replaceWith(input); diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts index 3b054d2..305f8ab 100644 --- a/src/features/chat/rendering/message-renderer.ts +++ b/src/features/chat/rendering/message-renderer.ts @@ -660,7 +660,7 @@ export class MessageRenderer { if (pre.parentElement?.classList.contains('qoderian-code-wrapper')) return; // Create wrapper - const wrapper = createEl('div', { cls: 'qoderian-code-wrapper' }); + const wrapper = createDiv({ cls: 'qoderian-code-wrapper' }); pre.parentElement?.insertBefore(wrapper, pre); wrapper.appendChild(pre); @@ -670,7 +670,7 @@ export class MessageRenderer { const match = code.className.match(/language-(\w+)/); if (match) { wrapper.classList.add('has-language'); - const label = createEl('span', { + const label = createSpan({ cls: 'qoderian-code-lang-label', text: match[1], }); diff --git a/src/features/chat/ui/image-context.ts b/src/features/chat/ui/image-context.ts index 0fc5751..47a83c8 100644 --- a/src/features/chat/ui/image-context.ts +++ b/src/features/chat/ui/image-context.ts @@ -86,27 +86,19 @@ export class ImageContextManager { this.dropOverlay = inputWrapper.createDiv({ cls: 'qoderian-drop-overlay' }); const dropContent = this.dropOverlay.createDiv({ cls: 'qoderian-drop-content' }); - const ownerDocument = inputWrapper.ownerDocument ?? window.document; - const svg = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('width', '32'); - svg.setAttribute('height', '32'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('stroke', 'currentColor'); - svg.setAttribute('stroke-width', '2'); - const pathEl = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); - pathEl.setAttribute('d', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'); - const polyline = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'polyline'); - polyline.setAttribute('points', '17 8 12 3 7 8'); - const line = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', '12'); - line.setAttribute('y1', '3'); - line.setAttribute('x2', '12'); - line.setAttribute('y2', '15'); - svg.appendChild(pathEl); - svg.appendChild(polyline); - svg.appendChild(line); - dropContent.appendChild(svg); + const svg = dropContent.createSvg('svg', { + attr: { + viewBox: '0 0 24 24', + width: '32', + height: '32', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + }, + }); + svg.createSvg('path', { attr: { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' } }); + svg.createSvg('polyline', { attr: { points: '17 8 12 3 7 8' } }); + svg.createSvg('line', { attr: { x1: '12', y1: '3', x2: '12', y2: '15' } }); dropContent.createSpan({ text: 'Drop image here' }); const dropZone = inputWrapper; diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index 49bfada..99ea996 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -649,31 +649,36 @@ export class ContextUsageMeter { const y2 = cy + radius * Math.sin(endRad); const gaugeEl = this.container.createDiv({ cls: 'qoderian-context-meter-gauge' }); - const svg = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', String(size)); - svg.setAttribute('height', String(size)); - svg.setAttribute('viewBox', `0 0 ${size} ${size}`); + const svg = gaugeEl.createSvg('svg', { + attr: { + width: String(size), + height: String(size), + viewBox: `0 0 ${size} ${size}`, + }, + }); const pathData = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2}`; - const backgroundPath = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); - backgroundPath.classList.add('qoderian-meter-bg'); - backgroundPath.setAttribute('d', pathData); - backgroundPath.setAttribute('fill', 'none'); - backgroundPath.setAttribute('stroke-width', String(strokeWidth)); - backgroundPath.setAttribute('stroke-linecap', 'round'); - - const fillPath = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); - fillPath.classList.add('qoderian-meter-fill'); - fillPath.setAttribute('d', pathData); - fillPath.setAttribute('fill', 'none'); - fillPath.setAttribute('stroke-width', String(strokeWidth)); - fillPath.setAttribute('stroke-linecap', 'round'); - fillPath.setAttribute('stroke-dasharray', String(this.circumference)); - fillPath.setAttribute('stroke-dashoffset', String(this.circumference)); - - svg.appendChild(backgroundPath); - svg.appendChild(fillPath); - gaugeEl.appendChild(svg); + svg.createSvg('path', { + cls: 'qoderian-meter-bg', + attr: { + d: pathData, + fill: 'none', + 'stroke-width': String(strokeWidth), + 'stroke-linecap': 'round', + }, + }); + + const fillPath = svg.createSvg('path', { + cls: 'qoderian-meter-fill', + attr: { + d: pathData, + fill: 'none', + 'stroke-width': String(strokeWidth), + 'stroke-linecap': 'round', + 'stroke-dasharray': String(this.circumference), + 'stroke-dashoffset': String(this.circumference), + }, + }); this.fillPath = fillPath; this.percentEl = this.container.createSpan({ cls: 'qoderian-context-meter-percent' }); diff --git a/src/features/chat/ui/status-panel.ts b/src/features/chat/ui/status-panel.ts index 11ff50f..aa7a336 100644 --- a/src/features/chat/ui/status-panel.ts +++ b/src/features/chat/ui/status-panel.ts @@ -85,18 +85,13 @@ export class StatusPanel { return; } - const ownerDocument = this.containerEl.ownerDocument ?? window.document; - // Create panel element (no border/background - seamless) - this.panelEl = ownerDocument.createElement('div'); - this.panelEl.className = 'qoderian-status-panel'; + this.panelEl = this.containerEl.createDiv({ cls: 'qoderian-status-panel' }); // Bash output container - hidden by default - this.bashOutputContainerEl = ownerDocument.createElement('div'); - this.bashOutputContainerEl.className = 'qoderian-status-panel-bash qoderian-hidden'; + this.bashOutputContainerEl = this.panelEl.createDiv({ cls: 'qoderian-status-panel-bash qoderian-hidden' }); - this.bashHeaderEl = ownerDocument.createElement('div'); - this.bashHeaderEl.className = 'qoderian-tool-header qoderian-status-panel-bash-header'; + this.bashHeaderEl = this.bashOutputContainerEl.createDiv({ cls: 'qoderian-tool-header qoderian-status-panel-bash-header' }); this.bashHeaderEl.setAttribute('tabindex', '0'); this.bashHeaderEl.setAttribute('role', 'button'); @@ -110,14 +105,7 @@ export class StatusPanel { this.bashHeaderEl.addEventListener('click', this.bashClickHandler); this.bashHeaderEl.addEventListener('keydown', this.bashKeydownHandler); - this.bashContentEl = ownerDocument.createElement('div'); - this.bashContentEl.className = 'qoderian-status-panel-bash-content'; - - this.bashOutputContainerEl.appendChild(this.bashHeaderEl); - this.bashOutputContainerEl.appendChild(this.bashContentEl); - this.panelEl.appendChild(this.bashOutputContainerEl); - - this.containerEl.appendChild(this.panelEl); + this.bashContentEl = this.bashOutputContainerEl.createDiv({ cls: 'qoderian-status-panel-bash-content' }); } /** @@ -174,32 +162,24 @@ export class StatusPanel { this.bashOutputContainerEl.removeClass('qoderian-hidden'); this.bashHeaderEl.empty(); this.bashContentEl.empty(); - const ownerDocument = this.bashHeaderEl.ownerDocument ?? window.document; - const headerIconEl = ownerDocument.createElement('span'); - headerIconEl.className = 'qoderian-tool-icon'; + const headerIconEl = this.bashHeaderEl.createSpan({ cls: 'qoderian-tool-icon' }); headerIconEl.setAttribute('aria-hidden', 'true'); setIcon(headerIconEl, 'terminal'); - this.bashHeaderEl.appendChild(headerIconEl); const latest = Array.from(this.currentBashOutputs.values()).at(-1); - const headerLabelEl = ownerDocument.createElement('span'); - headerLabelEl.className = 'qoderian-tool-label'; + const headerLabelEl = this.bashHeaderEl.createSpan({ cls: 'qoderian-tool-label' }); if (this.isBashExpanded) { headerLabelEl.textContent = t('chat.bangBash.commandPanel'); } else { headerLabelEl.textContent = latest ? this.truncateDescription(latest.command, 60) : t('chat.bangBash.commandPanel'); } - this.bashHeaderEl.appendChild(headerLabelEl); - const previewEl = ownerDocument.createElement('span'); - previewEl.className = 'qoderian-tool-current'; + const previewEl = this.bashHeaderEl.createSpan({ cls: 'qoderian-tool-current' }); previewEl.classList.toggle('qoderian-hidden', !this.isBashExpanded); - this.bashHeaderEl.appendChild(previewEl); - const summaryStatusEl = ownerDocument.createElement('span'); - summaryStatusEl.className = 'qoderian-tool-status'; + const summaryStatusEl = this.bashHeaderEl.createSpan({ cls: 'qoderian-tool-status' }); if (!this.isBashExpanded && latest) { summaryStatusEl.classList.add(`status-${latest.status}`); summaryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: latest.status })); @@ -208,19 +188,16 @@ export class StatusPanel { } else { summaryStatusEl.classList.add('qoderian-hidden'); } - this.bashHeaderEl.appendChild(summaryStatusEl); this.bashHeaderEl.setAttribute('aria-expanded', String(this.isBashExpanded)); - const actionsEl = ownerDocument.createElement('span'); - actionsEl.className = 'qoderian-status-panel-bash-actions'; + const actionsEl = this.bashHeaderEl.createSpan({ cls: 'qoderian-status-panel-bash-actions' }); this.appendActionButton(actionsEl, 'copy', t('chat.bangBash.copyAriaLabel'), 'copy', () => { void this.copyLatestBashOutput(); }); this.appendActionButton(actionsEl, 'clear', t('chat.bangBash.clearAriaLabel'), 'trash', () => { this.clearBashOutputs(); }); - this.bashHeaderEl.appendChild(actionsEl); this.bashContentEl.toggleClass('qoderian-hidden', !this.isBashExpanded); @@ -229,7 +206,7 @@ export class StatusPanel { } for (const info of this.currentBashOutputs.values()) { - this.bashContentEl.appendChild(this.renderBashEntry(info, ownerDocument)); + this.renderBashEntry(info, this.bashContentEl); } if (scroll) { @@ -238,38 +215,27 @@ export class StatusPanel { } } - private renderBashEntry(info: PanelBashOutput, ownerDocument: Document): HTMLElement { - const entryEl = ownerDocument.createElement('div'); - entryEl.className = 'qoderian-tool-call qoderian-status-panel-bash-entry'; + private renderBashEntry(info: PanelBashOutput, parent: HTMLElement): void { + const entryEl = parent.createDiv({ cls: 'qoderian-tool-call qoderian-status-panel-bash-entry' }); - const entryHeaderEl = ownerDocument.createElement('div'); - entryHeaderEl.className = 'qoderian-tool-header'; + const entryHeaderEl = entryEl.createDiv({ cls: 'qoderian-tool-header' }); entryHeaderEl.setAttribute('tabindex', '0'); entryHeaderEl.setAttribute('role', 'button'); - const entryIconEl = ownerDocument.createElement('span'); - entryIconEl.className = 'qoderian-tool-icon'; + const entryIconEl = entryHeaderEl.createSpan({ cls: 'qoderian-tool-icon' }); entryIconEl.setAttribute('aria-hidden', 'true'); setIcon(entryIconEl, 'dollar-sign'); - entryHeaderEl.appendChild(entryIconEl); - const entryLabelEl = ownerDocument.createElement('span'); - entryLabelEl.className = 'qoderian-tool-label'; + const entryLabelEl = entryHeaderEl.createSpan({ cls: 'qoderian-tool-label' }); entryLabelEl.textContent = t('chat.bangBash.commandLabel', { command: this.truncateDescription(info.command, 60) }); - entryHeaderEl.appendChild(entryLabelEl); - const entryStatusEl = ownerDocument.createElement('span'); - entryStatusEl.className = 'qoderian-tool-status'; + const entryStatusEl = entryHeaderEl.createSpan({ cls: 'qoderian-tool-status' }); entryStatusEl.classList.add(`status-${info.status}`); entryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: info.status })); if (info.status === 'completed') setIcon(entryStatusEl, 'check'); if (info.status === 'error') setIcon(entryStatusEl, 'x'); - entryHeaderEl.appendChild(entryStatusEl); - entryEl.appendChild(entryHeaderEl); - - const contentEl = ownerDocument.createElement('div'); - contentEl.className = 'qoderian-tool-content'; + const contentEl = entryEl.createDiv({ cls: 'qoderian-tool-content' }); const isEntryExpanded = this.bashEntryExpanded.get(info.id) ?? true; contentEl.classList.toggle('qoderian-hidden', !isEntryExpanded); entryHeaderEl.setAttribute('aria-expanded', String(isEntryExpanded)); @@ -286,22 +252,14 @@ export class StatusPanel { } }); - const rowEl = ownerDocument.createElement('div'); - rowEl.className = 'qoderian-tool-result-row'; + const rowEl = contentEl.createDiv({ cls: 'qoderian-tool-result-row' }); - const textEl = ownerDocument.createElement('span'); - textEl.className = 'qoderian-tool-result-text'; + const textEl = rowEl.createSpan({ cls: 'qoderian-tool-result-text' }); if (info.status === 'running' && !info.output) { textEl.textContent = t('chat.bangBash.running'); } else if (info.output) { textEl.textContent = info.output; } - - rowEl.appendChild(textEl); - contentEl.appendChild(rowEl); - - entryEl.appendChild(contentEl); - return entryEl; } private async copyLatestBashOutput(): Promise { @@ -324,8 +282,7 @@ export class StatusPanel { icon: string, action: () => void ): void { - const el = (parent.ownerDocument ?? window.document).createElement('span'); - el.className = `qoderian-status-panel-bash-action qoderian-status-panel-bash-action-${name}`; + const el = parent.createSpan({ cls: `qoderian-status-panel-bash-action qoderian-status-panel-bash-action-${name}` }); el.setAttribute('role', 'button'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', ariaLabel); @@ -341,7 +298,6 @@ export class StatusPanel { action(); } }); - parent.appendChild(el); } private toggleBashSection(): void { diff --git a/src/features/inline-edit/ui/markdown-preview.ts b/src/features/inline-edit/ui/markdown-preview.ts index 4d9e93b..785b43b 100644 --- a/src/features/inline-edit/ui/markdown-preview.ts +++ b/src/features/inline-edit/ui/markdown-preview.ts @@ -23,10 +23,8 @@ function emptyElement(container: HTMLElement): void { } function appendFallback(container: HTMLElement, markdown: string): void { - const fallback = container.ownerDocument.createElement('div'); - fallback.className = 'qoderian-inline-markdown-fallback'; + const fallback = container.createDiv({ cls: 'qoderian-inline-markdown-fallback' }); fallback.textContent = markdown; - container.appendChild(fallback); } export async function renderInlineEditMarkdownPreview({ diff --git a/src/features/inline-edit/ui/modal.ts b/src/features/inline-edit/ui/modal.ts index 2e149cf..75699c1 100644 --- a/src/features/inline-edit/ui/modal.ts +++ b/src/features/inline-edit/ui/modal.ts @@ -463,28 +463,18 @@ class InlineEditController { createInputDOM(): HTMLElement { const ownerDocument = this.getOwnerDocument(); - const container = ownerDocument.createElement('div'); - container.className = 'qoderian-inline-input-container'; + const container = createDiv({ cls: 'qoderian-inline-input-container' }); this.containerEl = container; - this.agentReplyEl = ownerDocument.createElement('div'); - this.agentReplyEl.className = 'qoderian-inline-agent-reply qoderian-hidden'; - container.appendChild(this.agentReplyEl); + this.agentReplyEl = container.createDiv({ cls: 'qoderian-inline-agent-reply qoderian-hidden' }); - const inputWrap = ownerDocument.createElement('div'); - inputWrap.className = 'qoderian-inline-input-wrap'; - container.appendChild(inputWrap); + const inputWrap = container.createDiv({ cls: 'qoderian-inline-input-wrap' }); - this.inputEl = ownerDocument.createElement('input'); - this.inputEl.type = 'text'; - this.inputEl.className = 'qoderian-inline-input'; + this.inputEl = inputWrap.createEl('input', { cls: 'qoderian-inline-input', type: 'text' }); this.inputEl.placeholder = this.mode === 'cursor' ? 'Insert instructions...' : 'Edit instructions...'; this.inputEl.spellcheck = false; - inputWrap.appendChild(this.inputEl); - this.spinnerEl = ownerDocument.createElement('div'); - this.spinnerEl.className = 'qoderian-inline-spinner qoderian-hidden'; - inputWrap.appendChild(this.spinnerEl); + this.spinnerEl = inputWrap.createDiv({ cls: 'qoderian-inline-spinner qoderian-hidden' }); const inlineCatalog = this.plugin.qoderServices.commandCatalog; this.slashCommandDropdown = new SlashCommandDropdown( @@ -527,19 +517,13 @@ class InlineEditController { } createDiffPreviewDOM(diffOps: DiffOp[]): HTMLElement { - const ownerDocument = this.getOwnerDocument(); - const previewEl = ownerDocument.createElement('div'); - previewEl.className = 'qoderian-inline-diff-preview'; + const previewEl = createDiv({ cls: 'qoderian-inline-diff-preview' }); - const bodyEl = ownerDocument.createElement('div'); - bodyEl.className = 'qoderian-inline-diff-preview-body markdown-rendered'; - previewEl.appendChild(bodyEl); + const bodyEl = previewEl.createDiv({ cls: 'qoderian-inline-diff-preview-body markdown-rendered' }); - const actionsEl = ownerDocument.createElement('div'); - actionsEl.className = 'qoderian-inline-preview-actions'; + const actionsEl = previewEl.createDiv({ cls: 'qoderian-inline-preview-actions' }); actionsEl.appendChild(this.createPreviewActionButton('Reject', 'reject', () => this.reject())); actionsEl.appendChild(this.createPreviewActionButton('Accept', 'accept', () => this.accept())); - previewEl.appendChild(actionsEl); void this.renderMarkdownDiffPreview(bodyEl, diffOps); return previewEl; @@ -550,10 +534,7 @@ class InlineEditController { variant: 'accept' | 'reject', onClick: () => void ): HTMLButtonElement { - const ownerDocument = this.getOwnerDocument(); - const button = ownerDocument.createElement('button'); - button.type = 'button'; - button.className = `qoderian-inline-preview-action ${variant}`; + const button = createEl('button', { cls: `qoderian-inline-preview-action ${variant}`, type: 'button' }); button.textContent = label; button.title = variant === 'accept' ? 'Accept (enter)' : 'Reject (esc)'; button.addEventListener('click', (event) => { @@ -580,9 +561,7 @@ class InlineEditController { for (const document of buildMarkdownDiffDocuments(diffOps)) { if (!document.markdown) continue; - const opEl = this.getOwnerDocument().createElement('div'); - opEl.className = `qoderian-diff-block ${getDiffBlockClass(document.type)}`; - container.appendChild(opEl); + const opEl = container.createDiv({ cls: `qoderian-diff-block ${getDiffBlockClass(document.type)}` }); await this.renderMarkdownPreview(opEl, document.markdown); } } @@ -670,7 +649,7 @@ class InlineEditController { if (!this.agentReplyEl || !this.containerEl) return; const replyEl = this.agentReplyEl; const renderVersion = ++this.agentReplyRenderVersion; - const renderedEl = this.getOwnerDocument().createElement('div'); + const renderedEl = createDiv(); replyEl.removeClass('qoderian-hidden'); replyEl.empty(); diff --git a/src/features/settings/ui/mcp-test-modal.ts b/src/features/settings/ui/mcp-test-modal.ts index 0e590af..0efe747 100644 --- a/src/features/settings/ui/mcp-test-modal.ts +++ b/src/features/settings/ui/mcp-test-modal.ts @@ -19,23 +19,20 @@ function formatToggleError(error: unknown): string { return error.message || 'Failed to update tool setting'; } -const SVG_NS = 'http://www.w3.org/2000/svg'; - function appendSpinnerSvg(container: HTMLElement): void { - const svg = container.ownerDocument.createElementNS(SVG_NS, 'svg'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('stroke', 'currentColor'); - svg.setAttribute('stroke-width', '2'); - - const path = container.ownerDocument.createElementNS(SVG_NS, 'path'); - path.setAttribute( - 'd', - 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83' - ); - svg.appendChild(path); - - container.appendChild(svg); + const svg = container.createSvg('svg', { + attr: { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + }, + }); + svg.createSvg('path', { + attr: { + d: 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83', + }, + }); } export class McpTestModal extends Modal { diff --git a/src/shared/icons.ts b/src/shared/icons.ts index 025a9cc..ddded2d 100644 --- a/src/shared/icons.ts +++ b/src/shared/icons.ts @@ -120,12 +120,15 @@ export function createIconSvg( svg.classList.add(...options.className.split(/\s+/).filter(Boolean)); } if (icon.kind === 'raw') { - const template = ownerDocument.createElement('template'); - template.innerHTML = icon.svg.trim(); - const sourceSvg = template.content.querySelector('svg'); + // Parsed as an inert SVG document instead of assigning innerHTML, so no + // markup is ever written to the live DOM (Obsidian review requirement). + const parsed = new DOMParser().parseFromString(icon.svg.trim(), 'image/svg+xml'); + const sourceSvg = parsed.documentElement.tagName.toLowerCase() === 'svg' + ? parsed.documentElement + : null; if (sourceSvg) { for (const child of Array.from(sourceSvg.children)) { - const clone = child.cloneNode(true) as SVGElement; + const clone = ownerDocument.importNode(child, true) as SVGElement; // The source artwork is an app-icon variant with a dark rounded-square // backdrop. In a compact model selector that backdrop turns into the // grey block seen on every option, so keep only the Qoder mark. diff --git a/src/shared/markdown/file-link.ts b/src/shared/markdown/file-link.ts index adb8bb5..21c7f48 100644 --- a/src/shared/markdown/file-link.ts +++ b/src/shared/markdown/file-link.ts @@ -110,15 +110,14 @@ function extractLinkPathFromTarget(linkTarget: string): string { * Click handling is done via event delegation in registerFileLinkHandler. */ function createWikilink( - ownerDocument: Document, linkTarget: string, displayText: string ): HTMLElement { - const link = ownerDocument.createElement('a'); - link.className = 'qoderian-file-link internal-link'; - link.textContent = displayText; - link.setAttribute('data-href', linkTarget); - link.setAttribute('href', linkTarget); + const link = createEl('a', { + cls: 'qoderian-file-link internal-link', + text: displayText, + attr: { 'data-href': linkTarget, href: linkTarget }, + }); return link; } @@ -164,7 +163,7 @@ export function registerFileLinkHandler( } function buildFragmentWithLinks(ownerDocument: Document, text: string, matches: WikilinkMatch[]): DocumentFragment { - const fragment = ownerDocument.createDocumentFragment(); + const fragment = createFragment(); let currentIndex = text.length; for (const { index, fullMatch, linkTarget, displayText } of matches) { @@ -177,7 +176,7 @@ function buildFragmentWithLinks(ownerDocument: Document, text: string, matches: ); } - fragment.insertBefore(createWikilink(ownerDocument, linkTarget, displayText), fragment.firstChild); + fragment.insertBefore(createWikilink(linkTarget, displayText), fragment.firstChild); currentIndex = index; } diff --git a/tests/helpers/mock-element.ts b/tests/helpers/mock-element.ts index 293bbdb..b55102f 100644 --- a/tests/helpers/mock-element.ts +++ b/tests/helpers/mock-element.ts @@ -21,6 +21,7 @@ export interface MockElement { createDiv: (opts?: { cls?: string; text?: string }) => MockElement; createSpan: (opts?: { cls?: string; text?: string }) => MockElement; createEl: (tag: string, opts?: { cls?: string; text?: string; attr?: Record }) => MockElement; + createSvg: (tag: string, opts?: { cls?: string; attr?: Record }) => MockElement; appendChild: (child: any) => any; insertBefore: (el: MockElement, ref: MockElement | null) => void; firstChild: MockElement | null; @@ -266,6 +267,17 @@ export function createMockEl(tag = 'div'): any { children.push(child); return child; }, + createSvg(tagName: string, opts?: { cls?: string; attr?: Record }) { + const child = createMockEl(tagName); + if (opts?.cls) child.addClass(opts.cls); + if (opts?.attr) { + for (const [name, value] of Object.entries(opts.attr)) { + child.setAttribute(name, value); + } + } + children.push(child); + return child; + }, appendChild(child: any) { children.push(child); return child; }, insertBefore(el: MockElement, _ref: MockElement | null) { children.unshift(el); }, diff --git a/tests/setup-window.ts b/tests/setup-window.ts index aa2cdd8..fd0e212 100644 --- a/tests/setup-window.ts +++ b/tests/setup-window.ts @@ -24,3 +24,110 @@ if (!('window' in globalThis)) { writable: true, }); } + +// Minimal ports of Obsidian's enhance.js DOM helpers (createEl, createDiv, +// createSpan, createSvg, createFragment). Production code relies on these +// globals; jsdom suites exercise them against a real document, and the +// document is resolved lazily so suites that install a mock document later +// still work. +interface TestDomElementInfo { + cls?: string | string[]; + text?: string; + attr?: Record; + title?: string; + value?: string; + type?: string; + placeholder?: string; + href?: string; + parent?: Node; + prepend?: boolean; +} + +type TestGlobals = Record & { + document?: Document; +}; + +// Obsidian's ambient declarations already type these globals, so the polyfill +// writes through an untyped view of globalThis. +const testGlobals = globalThis as unknown as TestGlobals; + +function resolveTestDocument(): Document { + const doc = testGlobals.document; + if (!doc) { + throw new Error('createEl helpers require a document in this test environment'); + } + return doc; +} + +function applyElementInfo( + el: Element, + info: TestDomElementInfo, + callback?: (el: Element) => void, +): void { + if (info.cls) { + const classes = Array.isArray(info.cls) ? info.cls : info.cls.split(/\s+/).filter(Boolean); + el.classList.add(...classes); + } + if (info.text) el.textContent = info.text; + if (info.attr) { + for (const [name, value] of Object.entries(info.attr)) { + if (value === null) continue; + el.setAttribute(name, String(value)); + } + } + if (info.title !== undefined) (el as HTMLElement).title = info.title; + if (info.value !== undefined && el instanceof HTMLInputElement) el.value = info.value; + if (info.type && el instanceof HTMLInputElement) el.type = info.type; + if (info.placeholder && el instanceof HTMLInputElement) el.placeholder = info.placeholder; + if (info.href && el instanceof HTMLAnchorElement) el.href = info.href; + callback?.(el); + if (info.parent) { + if (info.prepend) { + info.parent.insertBefore(el, info.parent.firstChild); + } else { + info.parent.appendChild(el); + } + } +} + +if (!testGlobals.createEl) { + testGlobals.createEl = ( + tag: string, + o?: TestDomElementInfo | string, + callback?: (el: Element) => void, + ): Element => { + const el = resolveTestDocument().createElement(tag); + applyElementInfo(el, typeof o === 'string' ? { cls: o } : o ?? {}, callback); + return el; + }; +} + +if (!testGlobals.createDiv) { + testGlobals.createDiv = (o?: TestDomElementInfo | string, callback?: (el: Element) => void) => + (testGlobals.createEl as (...args: unknown[]) => Element)('div', o, callback); +} + +if (!testGlobals.createSpan) { + testGlobals.createSpan = (o?: TestDomElementInfo | string, callback?: (el: Element) => void) => + (testGlobals.createEl as (...args: unknown[]) => Element)('span', o, callback); +} + +if (!testGlobals.createSvg) { + testGlobals.createSvg = ( + tag: string, + o?: TestDomElementInfo | string, + callback?: (el: Element) => void, + ): Element => { + const el = resolveTestDocument().createElementNS('http://www.w3.org/2000/svg', tag); + applyElementInfo(el, typeof o === 'string' ? { cls: o } : o ?? {}, callback); + return el; + }; +} + +if (!testGlobals.createFragment) { + testGlobals.createFragment = (callback?: (fragment: DocumentFragment) => void): DocumentFragment => { + const fragment = resolveTestDocument().createDocumentFragment(); + callback?.(fragment); + return fragment; + }; +} diff --git a/tests/unit/features/chat/ui/image-context.test.ts b/tests/unit/features/chat/ui/image-context.test.ts index b9df2cc..40adb8b 100644 --- a/tests/unit/features/chat/ui/image-context.test.ts +++ b/tests/unit/features/chat/ui/image-context.test.ts @@ -8,20 +8,6 @@ jest.mock('obsidian', () => ({ Notice: jest.fn(), })); -// Mock document.createElementNS for SVG elements created in setupDragAndDrop -const mockSvgElement = () => { - const el = createMockEl('svg'); - el.appendChild = jest.fn(); - return el; -}; - -beforeAll(() => { - if (typeof globalThis.document === 'undefined') { - (globalThis as any).document = {}; - } - (globalThis.document as any).createElementNS = jest.fn(() => mockSvgElement()); -}); - function createMockCallbacks() { return { onImagesChanged: jest.fn(), @@ -717,7 +703,6 @@ describe('ImageContextManager - Private Helpers', () => { body: mockBody, addEventListener: addEventSpy, removeEventListener: removeEventSpy, - createElementNS: jest.fn(() => mockSvgElement()), }; });