Skip to content

Commit 90c460f

Browse files
authored
feat: add diagram/code toggles for Mermaid blocks (#7)
## Summary - wrap Mermaid blocks in a shared container that exposes diagram/code toggle controls - persist and respect the selected view when rendering, defaulting to the diagram after successful renders - extend unit and Playwright tests to verify the new toggle behaviour ## Testing - `pnpm test` - `pnpm test:e2e` *(fails: Playwright build step hits existing CSS/module resolution issues)* ------ https://chatgpt.com/codex/tasks/task_b_68da27a8233c832bb3261ec43683ad71
1 parent b2d94ec commit 90c460f

File tree

9 files changed

+175
-36
lines changed

9 files changed

+175
-36
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
# testing
77
/coverage
8+
/test-results
89

910
# production
1011
/build

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
},
2020
"scripts": {
2121
"dev": "vite",
22-
"build": "tsc && vite build",
22+
"build": "tsc --project tsconfig.build.json && vite build",
2323
"preview": "vite preview",
2424
"fmt": "prettier --write '**/*.{tsx,ts,json,css,scss,md}'",
2525
"test": "vitest run",
2626
"test:watch": "vitest",
2727
"test:coverage": "vitest run --coverage",
28-
"test:e2e": "playwright test",
28+
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
2929
"zip": "pnpm run build && node src/zip.js"
3030
},
3131
"dependencies": {

src/contentScript/__tests__/index.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,45 @@ describe('content script', () => {
3939
expect(mermaidInitialize).toHaveBeenCalled()
4040
expect(mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^coderchart-/), expect.stringContaining('graph TD'))
4141
})
42+
43+
it('allows toggling between diagram and code views and persists selection', async () => {
44+
await import('../index')
45+
46+
await waitFor(() => {
47+
expect(document.querySelector('svg')).toBeInTheDocument()
48+
})
49+
50+
const container = document.querySelector('[data-coderchart-container="true"]') as HTMLElement
51+
expect(container).toBeTruthy()
52+
53+
await waitFor(() => {
54+
expect(container.dataset['view']).toBe('diagram')
55+
})
56+
57+
const diagramPane = container.querySelector('[data-coderchart-pane="diagram"]') as HTMLElement
58+
const codePane = container.querySelector('[data-coderchart-pane="code"]') as HTMLElement
59+
expect(diagramPane).toBeTruthy()
60+
expect(codePane).toBeTruthy()
61+
expect(diagramPane.style.display).toBe('block')
62+
expect(codePane.style.display).toBe('none')
63+
64+
const codeToggle = container.querySelector('button[data-coderchart-label="Code"]') as HTMLButtonElement
65+
expect(codeToggle).toBeTruthy()
66+
codeToggle.click()
67+
68+
expect(container.dataset['view']).toBe('code')
69+
expect(diagramPane.style.display).toBe('none')
70+
expect(codePane.style.display).toBe('block')
71+
72+
const codeElement = document.querySelector('code') as HTMLElement
73+
codeElement.textContent = 'graph TD; X-->Y;'
74+
75+
await waitFor(() => {
76+
expect(mermaidRender.mock.calls.length).toBeGreaterThanOrEqual(2)
77+
})
78+
79+
expect(container.dataset['view']).toBe('code')
80+
expect(diagramPane.style.display).toBe('none')
81+
expect(codePane.style.display).toBe('block')
82+
})
4283
})

src/contentScript/index.ts

Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ type BlockRegistryEntry = {
4848
id: string
4949
container: HTMLElement
5050
diagramHost: HTMLElement
51-
collapseButton: HTMLButtonElement
51+
codeHost: HTMLElement
52+
setView: (view: 'diagram' | 'code', options?: { userInitiated?: boolean }) => void
53+
userSelectedView: 'diagram' | 'code' | null
5254
downloadSvgButton: HTMLButtonElement
5355
downloadPngButton: HTMLButtonElement
5456
lastSvg: string | null
@@ -292,6 +294,12 @@ async function renderBlock(block: MermaidBlock) {
292294
registry.lastRenderId = renderId
293295
updateDownloadButtons(registry)
294296
cleanupGhostNodes(renderId, diagramHost.ownerDocument)
297+
298+
if (registry.userSelectedView !== 'code') {
299+
registry.setView('diagram')
300+
} else {
301+
registry.setView('code')
302+
}
295303
} catch (err) {
296304
if (container.dataset[BLOCK_DATA_SOURCE] !== source) {
297305
cleanupGhostNodes(renderId, diagramHost.ownerDocument)
@@ -305,6 +313,8 @@ async function renderBlock(block: MermaidBlock) {
305313
registry.lastRenderId = undefined
306314
updateDownloadButtons(registry)
307315
cleanupGhostNodes(renderId, diagramHost.ownerDocument)
316+
317+
registry.setView('diagram')
308318
}
309319
}
310320

@@ -342,12 +352,18 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry {
342352
actionGroup.style.alignItems = 'center'
343353
actionGroup.style.gap = '0.5rem'
344354

345-
const collapseButton = createActionButton(doc, 'Hide diagram')
355+
const viewToggleGroup = doc.createElement('div')
356+
viewToggleGroup.style.display = 'flex'
357+
viewToggleGroup.style.alignItems = 'center'
358+
viewToggleGroup.style.gap = '0.35rem'
346359

347-
const openRawButton = createActionButton(doc, 'Scroll to code')
348-
openRawButton.addEventListener('click', () => {
349-
pre.scrollIntoView({ behavior: 'smooth', block: 'center' })
350-
})
360+
const diagramToggle = createActionButton(doc, 'Diagram')
361+
diagramToggle.dataset['coderchartToggle'] = 'true'
362+
diagramToggle.setAttribute('aria-pressed', 'false')
363+
364+
const codeToggle = createActionButton(doc, 'Code')
365+
codeToggle.dataset['coderchartToggle'] = 'true'
366+
codeToggle.setAttribute('aria-pressed', 'false')
351367

352368
const downloadSvgButton = createActionButton(doc, 'Download SVG')
353369
downloadSvgButton.addEventListener('click', () => {
@@ -359,56 +375,95 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry {
359375
void handleDownloadPng(pre)
360376
})
361377

362-
collapseButton.addEventListener('click', () => {
363-
const isHidden = container.dataset['collapsed'] === 'true'
364-
if (isHidden) {
365-
container.dataset['collapsed'] = 'false'
366-
collapseButton.textContent = 'Hide diagram'
367-
} else {
368-
container.dataset['collapsed'] = 'true'
369-
collapseButton.textContent = 'Show diagram'
370-
}
371-
updateCollapsedState(container)
372-
})
378+
viewToggleGroup.append(diagramToggle, codeToggle)
373379

374-
actionGroup.append(collapseButton, openRawButton, downloadSvgButton, downloadPngButton)
380+
actionGroup.append(viewToggleGroup, downloadSvgButton, downloadPngButton)
375381
header.append(title, actionGroup)
376382
container.append(header)
377383

378384
const body = doc.createElement('div')
385+
body.dataset['coderchartBody'] = 'true'
379386
body.style.background = getBodyBackground()
380387
body.style.padding = '1rem'
381388
body.style.overflowX = 'auto'
382389

390+
const diagramHost = doc.createElement('div')
391+
diagramHost.dataset['coderchartPane'] = 'diagram'
392+
diagramHost.style.display = 'none'
393+
394+
const codeHost = doc.createElement('div')
395+
codeHost.dataset['coderchartPane'] = 'code'
396+
codeHost.style.display = 'none'
397+
398+
body.append(diagramHost, codeHost)
383399
container.append(body)
384400

385401
if (typeof pre.insertAdjacentElement === 'function') {
386-
pre.insertAdjacentElement('afterend', container)
402+
pre.insertAdjacentElement('beforebegin', container)
387403
} else if (pre.parentNode) {
388-
pre.parentNode.insertBefore(container, pre.nextSibling)
404+
pre.parentNode.insertBefore(container, pre)
389405
}
390406

407+
codeHost.append(pre)
408+
391409
const entry: BlockRegistryEntry = {
392410
id: blockId,
393411
container,
394-
diagramHost: body,
395-
collapseButton,
412+
diagramHost,
413+
codeHost,
414+
setView: () => undefined,
415+
userSelectedView: null,
396416
downloadSvgButton,
397417
downloadPngButton,
398418
lastSvg: null,
399419
}
420+
421+
const applyView = (view: 'diagram' | 'code', options?: { userInitiated?: boolean }) => {
422+
if (options?.userInitiated) {
423+
entry.userSelectedView = view
424+
}
425+
container.dataset['view'] = view
426+
diagramToggle.dataset['coderchartActive'] = view === 'diagram' ? 'true' : 'false'
427+
codeToggle.dataset['coderchartActive'] = view === 'code' ? 'true' : 'false'
428+
diagramToggle.setAttribute('aria-pressed', view === 'diagram' ? 'true' : 'false')
429+
codeToggle.setAttribute('aria-pressed', view === 'code' ? 'true' : 'false')
430+
updateButtonAppearance(diagramToggle)
431+
updateButtonAppearance(codeToggle)
432+
updatePaneVisibility(container)
433+
}
434+
435+
entry.setView = applyView
436+
437+
diagramToggle.addEventListener('click', () => {
438+
applyView('diagram', { userInitiated: true })
439+
})
440+
441+
codeToggle.addEventListener('click', () => {
442+
applyView('code', { userInitiated: true })
443+
})
444+
400445
processedBlocks.set(pre, entry)
401-
updateCollapsedState(container)
446+
applyView('code')
402447
updateDownloadButtons(entry)
403448

404449
return entry
405450
}
406451

407-
function updateCollapsedState(container: HTMLElement) {
408-
const isCollapsed = container.dataset['collapsed'] === 'true'
409-
const body = container.lastElementChild as HTMLElement | null
452+
function updatePaneVisibility(container: HTMLElement) {
453+
const body = container.querySelector('[data-coderchart-body="true"]') as HTMLElement | null
410454
if (!body) return
411-
body.style.display = isCollapsed ? 'none' : 'block'
455+
body.style.display = 'block'
456+
457+
const view = (container.dataset['view'] as 'diagram' | 'code') || 'diagram'
458+
const diagramHost = body.querySelector('[data-coderchart-pane="diagram"]') as HTMLElement | null
459+
const codeHost = body.querySelector('[data-coderchart-pane="code"]') as HTMLElement | null
460+
461+
if (diagramHost) {
462+
diagramHost.style.display = view === 'diagram' ? 'block' : 'none'
463+
}
464+
if (codeHost) {
465+
codeHost.style.display = view === 'code' ? 'block' : 'none'
466+
}
412467
}
413468

414469
function createActionButton(doc: Document, label: string): HTMLButtonElement {
@@ -421,19 +476,30 @@ function createActionButton(doc: Document, label: string): HTMLButtonElement {
421476
button.style.padding = '0.25rem 0.75rem'
422477
button.style.borderRadius = '0.5rem'
423478
button.style.border = getButtonBorder()
424-
button.style.background = getButtonBackground()
425-
button.style.color = getPrimaryTextColor()
479+
updateButtonAppearance(button)
426480
button.style.cursor = 'pointer'
427481
button.style.transition = 'background 150ms ease, border 150ms ease'
428482
button.addEventListener('mouseenter', () => {
429483
button.style.background = getButtonHoverBackground()
484+
if (button.dataset['coderchartToggle'] === 'true') {
485+
button.style.opacity = '1'
486+
}
430487
})
431488
button.addEventListener('mouseleave', () => {
432-
button.style.background = getButtonBackground()
489+
updateButtonAppearance(button)
433490
})
434491
return button
435492
}
436493

494+
function updateButtonAppearance(button: HTMLButtonElement) {
495+
const isToggle = button.dataset['coderchartToggle'] === 'true'
496+
const isActive = button.dataset['coderchartActive'] === 'true'
497+
button.style.border = getButtonBorder()
498+
button.style.background = isToggle && isActive ? getButtonHoverBackground() : getButtonBackground()
499+
button.style.color = getPrimaryTextColor()
500+
button.style.opacity = isToggle ? (isActive ? '1' : '0.75') : '1'
501+
}
502+
437503
function getPrimaryTextColor(): string {
438504
return isDarkMode() ? 'rgba(226, 232, 240, 0.95)' : '#1f2937'
439505
}
@@ -825,10 +891,7 @@ function refreshContainerStyles() {
825891
}
826892
entry.diagramHost.style.background = getBodyBackground()
827893
entry.container.querySelectorAll('button').forEach((element) => {
828-
const button = element as HTMLButtonElement
829-
button.style.border = getButtonBorder()
830-
button.style.background = getButtonBackground()
831-
button.style.color = getPrimaryTextColor()
894+
updateButtonAppearance(element as HTMLButtonElement)
832895
})
833896
})
834897
}

tests/e2e/extension.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,23 @@ test.describe('CoderChart extension e2e', () => {
3131
const diagram = page.locator('svg')
3232
await expect(diagram).toHaveCount(1)
3333
await expect(diagram).toBeVisible()
34+
35+
const container = page.locator('[data-coderchart-container="true"]').first()
36+
const diagramToggle = container.getByRole('button', { name: 'Diagram' })
37+
const codeToggle = container.getByRole('button', { name: 'Code' })
38+
const diagramPane = container.locator('[data-coderchart-pane="diagram"]')
39+
const codePane = container.locator('[data-coderchart-pane="code"]')
40+
41+
await expect(diagramToggle).toHaveAttribute('aria-pressed', 'true')
42+
await expect(codeToggle).toHaveAttribute('aria-pressed', 'false')
43+
await expect(diagramPane).toBeVisible()
44+
await expect(codePane).toBeHidden()
45+
46+
await codeToggle.click()
47+
48+
await expect(codeToggle).toHaveAttribute('aria-pressed', 'true')
49+
await expect(diagramToggle).toHaveAttribute('aria-pressed', 'false')
50+
await expect(diagramPane).toBeHidden()
51+
await expect(codePane).toBeVisible()
3452
})
3553
})

tests/e2e/fixtures/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { execSync } from 'node:child_process'
22
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
34
import { test as base } from '@playwright/test'
45

6+
const __filename = fileURLToPath(import.meta.url)
7+
const __dirname = path.dirname(__filename)
8+
59
const extensionFixture = base.extend<{ extensionPath: string }>({
610
extensionPath: [
711
async ({}, use) => {

tests/e2e/playwright.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { defineConfig, devices } from '@playwright/test'
22
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
34

5+
const __filename = fileURLToPath(import.meta.url)
6+
const __dirname = path.dirname(__filename)
47
const extensionPath = process.env.PLAYWRIGHT_EXTENSION_PATH ?? path.resolve(__dirname, '../../build')
58

69
export default defineConfig({

tsconfig.build.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["@types/chrome"]
5+
},
6+
"include": ["src"],
7+
"exclude": ["test", "tests", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**/*"]
8+
}

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default defineConfig({
1414
environment: 'happy-dom',
1515
setupFiles: ['test/setup/vitest.setup.ts'],
1616
globals: true,
17+
css: true,
1718
coverage: {
1819
provider: 'v8',
1920
reporter: ['text', 'html', 'lcov'],

0 commit comments

Comments
 (0)