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/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..1bf84b9 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,8 @@ async function renderBlock(block: MermaidBlock) { registry.lastRenderId = undefined updateDownloadButtons(registry) cleanupGhostNodes(renderId, diagramHost.ownerDocument) + + registry.setView('diagram') } } @@ -342,12 +352,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,56 +375,95 @@ 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) + updatePaneVisibility(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 } -function updateCollapsedState(container: HTMLElement) { - const isCollapsed = container.dataset['collapsed'] === 'true' - const body = container.lastElementChild as HTMLElement | null +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 + + if (diagramHost) { + diagramHost.style.display = view === 'diagram' ? 'block' : 'none' + } + if (codeHost) { + codeHost.style.display = view === 'code' ? 'block' : 'none' + } } function createActionButton(doc: Document, label: string): HTMLButtonElement { @@ -421,19 +476,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 +891,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() }) }) 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'],