Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# testing
/coverage
/test-results

# production
/build
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
41 changes: 41 additions & 0 deletions src/contentScript/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
131 changes: 97 additions & 34 deletions src/contentScript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -305,6 +313,8 @@ async function renderBlock(block: MermaidBlock) {
registry.lastRenderId = undefined
updateDownloadButtons(registry)
cleanupGhostNodes(renderId, diagramHost.ownerDocument)

registry.setView('diagram')
}
}

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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 {
Expand All @@ -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'
}
Expand Down Expand Up @@ -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)
})
})
}
18 changes: 18 additions & 0 deletions tests/e2e/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
4 changes: 4 additions & 0 deletions tests/e2e/fixtures/extension.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
8 changes: 8 additions & 0 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -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__/**/*"]
}
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down