From 41eac02cec9d351fd473fd053756268776712fef Mon Sep 17 00:00:00 2001 From: Harley Trung <44055+harley@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:42:53 +0700 Subject: [PATCH 1/4] Add view toggle for Mermaid renderings --- src/contentScript/__tests__/index.test.ts | 41 +++++++ src/contentScript/index.ts | 133 +++++++++++++++++----- tests/e2e/extension.spec.ts | 18 +++ 3 files changed, 161 insertions(+), 31 deletions(-) diff --git a/src/contentScript/__tests__/index.test.ts b/src/contentScript/__tests__/index.test.ts index 59a0ae9..ee154f5 100644 --- a/src/contentScript/__tests__/index.test.ts +++ b/src/contentScript/__tests__/index.test.ts @@ -39,4 +39,45 @@ describe('content script', () => { expect(mermaidInitialize).toHaveBeenCalled() expect(mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^coderchart-/), expect.stringContaining('graph TD')) }) + + it('allows toggling between diagram and code views and persists selection', async () => { + await import('../index') + + await waitFor(() => { + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + const container = document.querySelector('[data-coderchart-container="true"]') as HTMLElement + expect(container).toBeTruthy() + + await waitFor(() => { + expect(container.dataset['view']).toBe('diagram') + }) + + const diagramPane = container.querySelector('[data-coderchart-pane="diagram"]') as HTMLElement + const codePane = container.querySelector('[data-coderchart-pane="code"]') as HTMLElement + expect(diagramPane).toBeTruthy() + expect(codePane).toBeTruthy() + expect(diagramPane.style.display).toBe('block') + expect(codePane.style.display).toBe('none') + + const codeToggle = container.querySelector('button[data-coderchart-label="Code"]') as HTMLButtonElement + expect(codeToggle).toBeTruthy() + codeToggle.click() + + expect(container.dataset['view']).toBe('code') + expect(diagramPane.style.display).toBe('none') + expect(codePane.style.display).toBe('block') + + const codeElement = document.querySelector('code') as HTMLElement + codeElement.textContent = 'graph TD; X-->Y;' + + await waitFor(() => { + expect(mermaidRender.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + expect(container.dataset['view']).toBe('code') + expect(diagramPane.style.display).toBe('none') + expect(codePane.style.display).toBe('block') + }) }) diff --git a/src/contentScript/index.ts b/src/contentScript/index.ts index 6f16930..f5b4078 100644 --- a/src/contentScript/index.ts +++ b/src/contentScript/index.ts @@ -48,7 +48,9 @@ type BlockRegistryEntry = { id: string container: HTMLElement diagramHost: HTMLElement - collapseButton: HTMLButtonElement + codeHost: HTMLElement + setView: (view: 'diagram' | 'code', options?: { userInitiated?: boolean }) => void + userSelectedView: 'diagram' | 'code' | null downloadSvgButton: HTMLButtonElement downloadPngButton: HTMLButtonElement lastSvg: string | null @@ -292,6 +294,12 @@ async function renderBlock(block: MermaidBlock) { registry.lastRenderId = renderId updateDownloadButtons(registry) cleanupGhostNodes(renderId, diagramHost.ownerDocument) + + if (registry.userSelectedView !== 'code') { + registry.setView('diagram') + } else { + registry.setView('code') + } } catch (err) { if (container.dataset[BLOCK_DATA_SOURCE] !== source) { cleanupGhostNodes(renderId, diagramHost.ownerDocument) @@ -305,6 +313,12 @@ async function renderBlock(block: MermaidBlock) { registry.lastRenderId = undefined updateDownloadButtons(registry) cleanupGhostNodes(renderId, diagramHost.ownerDocument) + + if (registry.userSelectedView === 'code') { + registry.setView('code') + } else { + registry.setView('diagram') + } } } @@ -342,12 +356,18 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { actionGroup.style.alignItems = 'center' actionGroup.style.gap = '0.5rem' - const collapseButton = createActionButton(doc, 'Hide diagram') + const viewToggleGroup = doc.createElement('div') + viewToggleGroup.style.display = 'flex' + viewToggleGroup.style.alignItems = 'center' + viewToggleGroup.style.gap = '0.35rem' - const openRawButton = createActionButton(doc, 'Scroll to code') - openRawButton.addEventListener('click', () => { - pre.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }) + const diagramToggle = createActionButton(doc, 'Diagram') + diagramToggle.dataset['coderchartToggle'] = 'true' + diagramToggle.setAttribute('aria-pressed', 'false') + + const codeToggle = createActionButton(doc, 'Code') + codeToggle.dataset['coderchartToggle'] = 'true' + codeToggle.setAttribute('aria-pressed', 'false') const downloadSvgButton = createActionButton(doc, 'Download SVG') downloadSvgButton.addEventListener('click', () => { @@ -359,46 +379,75 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { void handleDownloadPng(pre) }) - collapseButton.addEventListener('click', () => { - const isHidden = container.dataset['collapsed'] === 'true' - if (isHidden) { - container.dataset['collapsed'] = 'false' - collapseButton.textContent = 'Hide diagram' - } else { - container.dataset['collapsed'] = 'true' - collapseButton.textContent = 'Show diagram' - } - updateCollapsedState(container) - }) + viewToggleGroup.append(diagramToggle, codeToggle) - actionGroup.append(collapseButton, openRawButton, downloadSvgButton, downloadPngButton) + actionGroup.append(viewToggleGroup, downloadSvgButton, downloadPngButton) header.append(title, actionGroup) container.append(header) const body = doc.createElement('div') + body.dataset['coderchartBody'] = 'true' body.style.background = getBodyBackground() body.style.padding = '1rem' body.style.overflowX = 'auto' + const diagramHost = doc.createElement('div') + diagramHost.dataset['coderchartPane'] = 'diagram' + diagramHost.style.display = 'none' + + const codeHost = doc.createElement('div') + codeHost.dataset['coderchartPane'] = 'code' + codeHost.style.display = 'none' + + body.append(diagramHost, codeHost) container.append(body) if (typeof pre.insertAdjacentElement === 'function') { - pre.insertAdjacentElement('afterend', container) + pre.insertAdjacentElement('beforebegin', container) } else if (pre.parentNode) { - pre.parentNode.insertBefore(container, pre.nextSibling) + pre.parentNode.insertBefore(container, pre) } + codeHost.append(pre) + const entry: BlockRegistryEntry = { id: blockId, container, - diagramHost: body, - collapseButton, + diagramHost, + codeHost, + setView: () => undefined, + userSelectedView: null, downloadSvgButton, downloadPngButton, lastSvg: null, } + + const applyView = (view: 'diagram' | 'code', options?: { userInitiated?: boolean }) => { + if (options?.userInitiated) { + entry.userSelectedView = view + } + container.dataset['view'] = view + diagramToggle.dataset['coderchartActive'] = view === 'diagram' ? 'true' : 'false' + codeToggle.dataset['coderchartActive'] = view === 'code' ? 'true' : 'false' + diagramToggle.setAttribute('aria-pressed', view === 'diagram' ? 'true' : 'false') + codeToggle.setAttribute('aria-pressed', view === 'code' ? 'true' : 'false') + updateButtonAppearance(diagramToggle) + updateButtonAppearance(codeToggle) + updateCollapsedState(container) + } + + entry.setView = applyView + + diagramToggle.addEventListener('click', () => { + applyView('diagram', { userInitiated: true }) + }) + + codeToggle.addEventListener('click', () => { + applyView('code', { userInitiated: true }) + }) + processedBlocks.set(pre, entry) - updateCollapsedState(container) + applyView('code') updateDownloadButtons(entry) return entry @@ -406,9 +455,23 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { function updateCollapsedState(container: HTMLElement) { const isCollapsed = container.dataset['collapsed'] === 'true' - const body = container.lastElementChild as HTMLElement | null + const body = container.querySelector('[data-coderchart-body="true"]') as HTMLElement | null if (!body) return body.style.display = isCollapsed ? 'none' : 'block' + + const view = (container.dataset['view'] as 'diagram' | 'code') || 'diagram' + const diagramHost = body.querySelector('[data-coderchart-pane="diagram"]') as HTMLElement | null + const codeHost = body.querySelector('[data-coderchart-pane="code"]') as HTMLElement | null + + const showDiagram = !isCollapsed && view === 'diagram' + const showCode = !isCollapsed && view === 'code' + + if (diagramHost) { + diagramHost.style.display = showDiagram ? 'block' : 'none' + } + if (codeHost) { + codeHost.style.display = showCode ? 'block' : 'none' + } } function createActionButton(doc: Document, label: string): HTMLButtonElement { @@ -421,19 +484,30 @@ function createActionButton(doc: Document, label: string): HTMLButtonElement { button.style.padding = '0.25rem 0.75rem' button.style.borderRadius = '0.5rem' button.style.border = getButtonBorder() - button.style.background = getButtonBackground() - button.style.color = getPrimaryTextColor() + updateButtonAppearance(button) button.style.cursor = 'pointer' button.style.transition = 'background 150ms ease, border 150ms ease' button.addEventListener('mouseenter', () => { button.style.background = getButtonHoverBackground() + if (button.dataset['coderchartToggle'] === 'true') { + button.style.opacity = '1' + } }) button.addEventListener('mouseleave', () => { - button.style.background = getButtonBackground() + updateButtonAppearance(button) }) return button } +function updateButtonAppearance(button: HTMLButtonElement) { + const isToggle = button.dataset['coderchartToggle'] === 'true' + const isActive = button.dataset['coderchartActive'] === 'true' + button.style.border = getButtonBorder() + button.style.background = isToggle && isActive ? getButtonHoverBackground() : getButtonBackground() + button.style.color = getPrimaryTextColor() + button.style.opacity = isToggle ? (isActive ? '1' : '0.75') : '1' +} + function getPrimaryTextColor(): string { return isDarkMode() ? 'rgba(226, 232, 240, 0.95)' : '#1f2937' } @@ -825,10 +899,7 @@ function refreshContainerStyles() { } entry.diagramHost.style.background = getBodyBackground() entry.container.querySelectorAll('button').forEach((element) => { - const button = element as HTMLButtonElement - button.style.border = getButtonBorder() - button.style.background = getButtonBackground() - button.style.color = getPrimaryTextColor() + updateButtonAppearance(element as HTMLButtonElement) }) }) } diff --git a/tests/e2e/extension.spec.ts b/tests/e2e/extension.spec.ts index 7510676..c3686da 100644 --- a/tests/e2e/extension.spec.ts +++ b/tests/e2e/extension.spec.ts @@ -31,5 +31,23 @@ test.describe('CoderChart extension e2e', () => { const diagram = page.locator('svg') await expect(diagram).toHaveCount(1) await expect(diagram).toBeVisible() + + const container = page.locator('[data-coderchart-container="true"]').first() + const diagramToggle = container.getByRole('button', { name: 'Diagram' }) + const codeToggle = container.getByRole('button', { name: 'Code' }) + const diagramPane = container.locator('[data-coderchart-pane="diagram"]') + const codePane = container.locator('[data-coderchart-pane="code"]') + + await expect(diagramToggle).toHaveAttribute('aria-pressed', 'true') + await expect(codeToggle).toHaveAttribute('aria-pressed', 'false') + await expect(diagramPane).toBeVisible() + await expect(codePane).toBeHidden() + + await codeToggle.click() + + await expect(codeToggle).toHaveAttribute('aria-pressed', 'true') + await expect(diagramToggle).toHaveAttribute('aria-pressed', 'false') + await expect(diagramPane).toBeHidden() + await expect(codePane).toBeVisible() }) }) From e7aac16936178159939ac55eaec6397ef84a1191 Mon Sep 17 00:00:00 2001 From: Harley Trung Date: Mon, 29 Sep 2025 13:58:42 +0700 Subject: [PATCH 2/4] Fix CSS syntax error in e2e tests - Create separate tsconfig.build.json that excludes test files from build - Update build script to use build-specific TypeScript configuration - Add CSS handling configuration to vitest.config.ts - Fix ES module __dirname issues in Playwright configs - Update e2e test script to use correct Playwright config path - Add test-results/ directory to gitignore This resolves the syntax error when running 'pnpm test:e2e' that was caused by the build process trying to compile CSS files as JavaScript. --- .gitignore | 1 + package.json | 4 ++-- tests/e2e/fixtures/extension.ts | 4 ++++ tests/e2e/playwright.config.ts | 3 +++ tsconfig.build.json | 8 ++++++++ vitest.config.ts | 1 + 6 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tsconfig.build.json diff --git a/.gitignore b/.gitignore index bda275b..74d646c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # testing /coverage +/test-results # production /build diff --git a/package.json b/package.json index ba32c71..059ee40 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,13 @@ }, "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc --project tsconfig.build.json && vite build", "preview": "vite preview", "fmt": "prettier --write '**/*.{tsx,ts,json,css,scss,md}'", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", + "test:e2e": "playwright test --config=tests/e2e/playwright.config.ts", "zip": "pnpm run build && node src/zip.js" }, "dependencies": { diff --git a/tests/e2e/fixtures/extension.ts b/tests/e2e/fixtures/extension.ts index 8aecd6c..ae0bdde 100644 --- a/tests/e2e/fixtures/extension.ts +++ b/tests/e2e/fixtures/extension.ts @@ -1,7 +1,11 @@ import { execSync } from 'node:child_process' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { test as base } from '@playwright/test' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + const extensionFixture = base.extend<{ extensionPath: string }>({ extensionPath: [ async ({}, use) => { diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 0495ca9..b3e876c 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -1,6 +1,9 @@ import { defineConfig, devices } from '@playwright/test' import path from 'node:path' +import { fileURLToPath } from 'node:url' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) const extensionPath = process.env.PLAYWRIGHT_EXTENSION_PATH ?? path.resolve(__dirname, '../../build') export default defineConfig({ diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..cffdd18 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["@types/chrome"] + }, + "include": ["src"], + "exclude": ["test", "tests", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**/*"] +} diff --git a/vitest.config.ts b/vitest.config.ts index e97759c..4f1aa5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ environment: 'happy-dom', setupFiles: ['test/setup/vitest.setup.ts'], globals: true, + css: true, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], From 16e748bba3c3edfcff023146f4531f8c1bf507c2 Mon Sep 17 00:00:00 2001 From: Harley Trung Date: Mon, 29 Sep 2025 14:02:13 +0700 Subject: [PATCH 3/4] Fix error handling UX: always show diagram pane on render errors When Mermaid rendering fails, the error message is displayed in the diagram pane. Previously, if the user had selected 'code' view, they wouldn't see the error message. Now the view is always switched to 'diagram' on rendering errors to ensure users are notified of failures, improving the user experience. --- src/contentScript/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/contentScript/index.ts b/src/contentScript/index.ts index f5b4078..482957d 100644 --- a/src/contentScript/index.ts +++ b/src/contentScript/index.ts @@ -314,11 +314,7 @@ async function renderBlock(block: MermaidBlock) { updateDownloadButtons(registry) cleanupGhostNodes(renderId, diagramHost.ownerDocument) - if (registry.userSelectedView === 'code') { - registry.setView('code') - } else { - registry.setView('diagram') - } + registry.setView('diagram') } } From bcaa9368e5c962c793ef357216ae7cfd97987002 Mon Sep 17 00:00:00 2001 From: Harley Trung Date: Mon, 29 Sep 2025 14:03:35 +0700 Subject: [PATCH 4/4] Refactor: Remove dead collapse code and improve function naming - Rename updateCollapsedState() to updatePaneVisibility() - Remove unused collapse logic and isCollapsed checks - Simplify pane visibility logic to only handle diagram/code toggle - Improve code maintainability by removing dead code paths The collapse functionality was removed, making the collapse-related logic unnecessary. This cleanup makes the code more readable and focused on its actual purpose. --- src/contentScript/index.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/contentScript/index.ts b/src/contentScript/index.ts index 482957d..1bf84b9 100644 --- a/src/contentScript/index.ts +++ b/src/contentScript/index.ts @@ -429,7 +429,7 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { codeToggle.setAttribute('aria-pressed', view === 'code' ? 'true' : 'false') updateButtonAppearance(diagramToggle) updateButtonAppearance(codeToggle) - updateCollapsedState(container) + updatePaneVisibility(container) } entry.setView = applyView @@ -449,24 +449,20 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { return entry } -function updateCollapsedState(container: HTMLElement) { - const isCollapsed = container.dataset['collapsed'] === 'true' +function updatePaneVisibility(container: HTMLElement) { const body = container.querySelector('[data-coderchart-body="true"]') as HTMLElement | null if (!body) return - body.style.display = isCollapsed ? 'none' : 'block' + body.style.display = 'block' const view = (container.dataset['view'] as 'diagram' | 'code') || 'diagram' const diagramHost = body.querySelector('[data-coderchart-pane="diagram"]') as HTMLElement | null const codeHost = body.querySelector('[data-coderchart-pane="code"]') as HTMLElement | null - const showDiagram = !isCollapsed && view === 'diagram' - const showCode = !isCollapsed && view === 'code' - if (diagramHost) { - diagramHost.style.display = showDiagram ? 'block' : 'none' + diagramHost.style.display = view === 'diagram' ? 'block' : 'none' } if (codeHost) { - codeHost.style.display = showCode ? 'block' : 'none' + codeHost.style.display = view === 'code' ? 'block' : 'none' } }