-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add export dropdown for diagram downloads #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -51,8 +51,10 @@ type BlockRegistryEntry = { | |||||||
codeHost: HTMLElement | ||||||||
setView: (view: 'diagram' | 'code', options?: { userInitiated?: boolean }) => void | ||||||||
userSelectedView: 'diagram' | 'code' | null | ||||||||
downloadSvgButton: HTMLButtonElement | ||||||||
downloadPngButton: HTMLButtonElement | ||||||||
exportButton: HTMLButtonElement | ||||||||
exportSvgItem: HTMLButtonElement | ||||||||
exportPngItem: HTMLButtonElement | ||||||||
closeExportMenu: () => void | ||||||||
lastSvg: string | null | ||||||||
lastRenderId?: string | ||||||||
} | ||||||||
|
@@ -365,19 +367,18 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { | |||||||
codeToggle.dataset['coderchartToggle'] = 'true' | ||||||||
codeToggle.setAttribute('aria-pressed', 'false') | ||||||||
|
||||||||
const downloadSvgButton = createActionButton(doc, 'Download SVG') | ||||||||
downloadSvgButton.addEventListener('click', () => { | ||||||||
handleDownloadSvg(pre) | ||||||||
}) | ||||||||
|
||||||||
const downloadPngButton = createActionButton(doc, 'Download PNG') | ||||||||
downloadPngButton.addEventListener('click', () => { | ||||||||
void handleDownloadPng(pre) | ||||||||
const exportDropdown = createExportDropdown(doc, { | ||||||||
onSvg: () => { | ||||||||
handleDownloadSvg(pre) | ||||||||
}, | ||||||||
onPng: () => { | ||||||||
void handleDownloadPng(pre) | ||||||||
}, | ||||||||
}) | ||||||||
|
||||||||
viewToggleGroup.append(diagramToggle, codeToggle) | ||||||||
|
||||||||
actionGroup.append(viewToggleGroup, downloadSvgButton, downloadPngButton) | ||||||||
actionGroup.append(viewToggleGroup, exportDropdown.container) | ||||||||
header.append(title, actionGroup) | ||||||||
container.append(header) | ||||||||
|
||||||||
|
@@ -413,8 +414,10 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { | |||||||
codeHost, | ||||||||
setView: () => undefined, | ||||||||
userSelectedView: null, | ||||||||
downloadSvgButton, | ||||||||
downloadPngButton, | ||||||||
exportButton: exportDropdown.trigger, | ||||||||
exportSvgItem: exportDropdown.svgItem, | ||||||||
exportPngItem: exportDropdown.pngItem, | ||||||||
closeExportMenu: exportDropdown.close, | ||||||||
lastSvg: null, | ||||||||
} | ||||||||
|
||||||||
|
@@ -491,6 +494,176 @@ function createActionButton(doc: Document, label: string): HTMLButtonElement { | |||||||
return button | ||||||||
} | ||||||||
|
||||||||
type ExportDropdown = { | ||||||||
container: HTMLElement | ||||||||
trigger: HTMLButtonElement | ||||||||
svgItem: HTMLButtonElement | ||||||||
pngItem: HTMLButtonElement | ||||||||
close: () => void | ||||||||
} | ||||||||
|
||||||||
function createExportDropdown( | ||||||||
doc: Document, | ||||||||
handlers: { onSvg: () => void; onPng: () => void }, | ||||||||
): ExportDropdown { | ||||||||
const container = doc.createElement('div') | ||||||||
container.style.position = 'relative' | ||||||||
container.style.display = 'flex' | ||||||||
|
||||||||
const trigger = createActionButton(doc, 'Export') | ||||||||
trigger.textContent = 'Export ▾' | ||||||||
trigger.dataset['coderchartLabel'] = 'Export' | ||||||||
trigger.setAttribute('aria-haspopup', 'menu') | ||||||||
trigger.setAttribute('aria-expanded', 'false') | ||||||||
|
||||||||
const menu = doc.createElement('div') | ||||||||
menu.dataset['coderchartExportMenu'] = 'true' | ||||||||
menu.style.position = 'absolute' | ||||||||
menu.style.top = 'calc(100% + 0.35rem)' | ||||||||
menu.style.right = '0' | ||||||||
menu.style.display = 'flex' | ||||||||
menu.style.flexDirection = 'column' | ||||||||
menu.style.gap = '0.25rem' | ||||||||
menu.style.padding = '0.35rem' | ||||||||
menu.style.borderRadius = '0.5rem' | ||||||||
menu.style.border = getButtonBorder() | ||||||||
menu.style.background = getBodyBackground() | ||||||||
menu.style.boxShadow = isDarkMode() | ||||||||
? '0 12px 24px rgba(15, 23, 42, 0.45)' | ||||||||
: '0 12px 24px rgba(15, 23, 42, 0.18)' | ||||||||
menu.style.minWidth = '8.5rem' | ||||||||
menu.style.zIndex = '2147483647' | ||||||||
menu.hidden = true | ||||||||
menu.setAttribute('role', 'menu') | ||||||||
|
||||||||
const svgItem = createExportMenuItem(doc, 'Export SVG') | ||||||||
svgItem.addEventListener('click', (event) => { | ||||||||
event.stopPropagation() | ||||||||
if (svgItem.disabled) return | ||||||||
closeMenu() | ||||||||
handlers.onSvg() | ||||||||
}) | ||||||||
|
||||||||
const pngItem = createExportMenuItem(doc, 'Export PNG') | ||||||||
pngItem.addEventListener('click', (event) => { | ||||||||
event.stopPropagation() | ||||||||
if (pngItem.disabled) return | ||||||||
closeMenu() | ||||||||
handlers.onPng() | ||||||||
}) | ||||||||
|
||||||||
menu.append(svgItem, pngItem) | ||||||||
|
||||||||
const handleDocumentClick = (event: MouseEvent) => { | ||||||||
if (!container.contains(event.target as Node)) { | ||||||||
closeMenu() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
const handleDocumentKeydown = (event: KeyboardEvent) => { | ||||||||
if (event.key === 'Escape') { | ||||||||
closeMenu() | ||||||||
trigger.focus() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
const openMenu = () => { | ||||||||
if (!menu.hidden) return | ||||||||
menu.hidden = false | ||||||||
trigger.setAttribute('aria-expanded', 'true') | ||||||||
doc.addEventListener('click', handleDocumentClick, true) | ||||||||
doc.addEventListener('keydown', handleDocumentKeydown, true) | ||||||||
} | ||||||||
|
||||||||
const closeMenu = () => { | ||||||||
if (menu.hidden) return | ||||||||
menu.hidden = true | ||||||||
trigger.setAttribute('aria-expanded', 'false') | ||||||||
doc.removeEventListener('click', handleDocumentClick, true) | ||||||||
doc.removeEventListener('keydown', handleDocumentKeydown, true) | ||||||||
} | ||||||||
|
||||||||
trigger.addEventListener('click', (event) => { | ||||||||
event.stopPropagation() | ||||||||
if (trigger.disabled) { | ||||||||
closeMenu() | ||||||||
return | ||||||||
} | ||||||||
if (menu.hidden) { | ||||||||
openMenu() | ||||||||
} else { | ||||||||
closeMenu() | ||||||||
} | ||||||||
}) | ||||||||
|
||||||||
trigger.addEventListener('keydown', (event) => { | ||||||||
if (event.key === 'ArrowDown' && menu.hidden && !trigger.disabled) { | ||||||||
event.preventDefault() | ||||||||
openMenu() | ||||||||
svgItem.focus() | ||||||||
} | ||||||||
}) | ||||||||
|
||||||||
menu.addEventListener('keydown', (event) => { | ||||||||
if (event.key === 'Tab') { | ||||||||
closeMenu() | ||||||||
} | ||||||||
}) | ||||||||
|
||||||||
container.append(trigger, menu) | ||||||||
|
||||||||
updateExportMenuItemState(svgItem) | ||||||||
updateExportMenuItemState(pngItem) | ||||||||
|
||||||||
return { | ||||||||
container, | ||||||||
trigger, | ||||||||
svgItem, | ||||||||
pngItem, | ||||||||
close: closeMenu, | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
function createExportMenuItem(doc: Document, label: string): HTMLButtonElement { | ||||||||
const button = doc.createElement('button') | ||||||||
button.type = 'button' | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better accessibility, this button, which acts as a menu item, should have the
Suggested change
|
||||||||
button.textContent = label | ||||||||
button.dataset['coderchartLabel'] = label | ||||||||
button.dataset['coderchartMenuItem'] = 'true' | ||||||||
button.style.fontSize = '0.75rem' | ||||||||
button.style.fontWeight = '500' | ||||||||
button.style.padding = '0.4rem 0.75rem' | ||||||||
button.style.border = 'none' | ||||||||
button.style.borderRadius = '0.4rem' | ||||||||
button.style.background = 'transparent' | ||||||||
button.style.color = getPrimaryTextColor() | ||||||||
button.style.textAlign = 'left' | ||||||||
button.style.cursor = 'pointer' | ||||||||
button.style.transition = 'background 150ms ease, opacity 150ms ease' | ||||||||
|
||||||||
const resetBackground = () => { | ||||||||
button.style.background = 'transparent' | ||||||||
} | ||||||||
|
||||||||
button.addEventListener('mouseenter', () => { | ||||||||
if (button.disabled) return | ||||||||
button.style.background = getButtonHoverBackground() | ||||||||
}) | ||||||||
button.addEventListener('mouseleave', resetBackground) | ||||||||
button.addEventListener('blur', resetBackground) | ||||||||
button.addEventListener('focus', () => { | ||||||||
if (button.disabled) return | ||||||||
button.style.background = getButtonHoverBackground() | ||||||||
}) | ||||||||
|
||||||||
return button | ||||||||
} | ||||||||
|
||||||||
function updateExportMenuItemState(button: HTMLButtonElement) { | ||||||||
button.style.opacity = button.disabled ? '0.55' : '1' | ||||||||
button.style.cursor = button.disabled ? 'not-allowed' : 'pointer' | ||||||||
} | ||||||||
|
||||||||
function updateButtonAppearance(button: HTMLButtonElement) { | ||||||||
const isToggle = button.dataset['coderchartToggle'] === 'true' | ||||||||
const isActive = button.dataset['coderchartActive'] === 'true' | ||||||||
|
@@ -584,8 +757,14 @@ function cleanupGhostNodes(renderId: string, doc: Document) { | |||||||
|
||||||||
function updateDownloadButtons(entry: BlockRegistryEntry) { | ||||||||
const hasRenderableSvg = Boolean(entry.lastSvg) | ||||||||
entry.downloadSvgButton.disabled = !hasRenderableSvg | ||||||||
entry.downloadPngButton.disabled = !hasRenderableSvg | ||||||||
entry.exportButton.disabled = !hasRenderableSvg | ||||||||
entry.exportSvgItem.disabled = !hasRenderableSvg | ||||||||
entry.exportPngItem.disabled = !hasRenderableSvg | ||||||||
updateExportMenuItemState(entry.exportSvgItem) | ||||||||
updateExportMenuItemState(entry.exportPngItem) | ||||||||
if (!hasRenderableSvg) { | ||||||||
entry.closeExportMenu() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
function handleDownloadSvg(pre: HTMLElement) { | ||||||||
|
@@ -594,6 +773,8 @@ function handleDownloadSvg(pre: HTMLElement) { | |||||||
return | ||||||||
} | ||||||||
|
||||||||
entry.closeExportMenu() | ||||||||
|
||||||||
const blob = new Blob([entry.lastSvg], { type: 'image/svg+xml;charset=utf-8' }) | ||||||||
triggerDownload(blob, buildFilename(entry, 'svg')) | ||||||||
} | ||||||||
|
@@ -604,10 +785,13 @@ async function handleDownloadPng(pre: HTMLElement) { | |||||||
return | ||||||||
} | ||||||||
|
||||||||
const button = entry.downloadPngButton | ||||||||
const defaultLabel = button.dataset['coderchartLabel'] || 'Download PNG' | ||||||||
entry.closeExportMenu() | ||||||||
|
||||||||
const button = entry.exportPngItem | ||||||||
const defaultLabel = button.dataset['coderchartLabel'] || 'Export PNG' | ||||||||
button.disabled = true | ||||||||
button.textContent = PNG_PREPARING_LABEL | ||||||||
updateExportMenuItemState(button) | ||||||||
|
||||||||
try { | ||||||||
const pngBlob = await convertSvgToPng(entry.lastSvg) | ||||||||
|
@@ -616,6 +800,8 @@ async function handleDownloadPng(pre: HTMLElement) { | |||||||
console.warn('Failed to export diagram as PNG', err) | ||||||||
} finally { | ||||||||
button.textContent = defaultLabel | ||||||||
button.disabled = false | ||||||||
updateExportMenuItemState(button) | ||||||||
Comment on lines
+803
to
+804
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||
updateDownloadButtons(entry) | ||||||||
} | ||||||||
} | ||||||||
|
@@ -891,7 +1077,23 @@ function refreshContainerStyles() { | |||||||
} | ||||||||
entry.diagramHost.style.background = getBodyBackground() | ||||||||
entry.container.querySelectorAll('button').forEach((element) => { | ||||||||
updateButtonAppearance(element as HTMLButtonElement) | ||||||||
const button = element as HTMLButtonElement | ||||||||
if (button.dataset['coderchartMenuItem'] === 'true') { | ||||||||
button.style.color = getPrimaryTextColor() | ||||||||
updateExportMenuItemState(button) | ||||||||
} else { | ||||||||
updateButtonAppearance(button) | ||||||||
} | ||||||||
}) | ||||||||
entry.container | ||||||||
.querySelectorAll('[data-coderchart-export-menu="true"]') | ||||||||
.forEach((menuElement) => { | ||||||||
const menu = menuElement as HTMLElement | ||||||||
menu.style.border = getButtonBorder() | ||||||||
menu.style.background = getBodyBackground() | ||||||||
menu.style.boxShadow = isDarkMode() | ||||||||
? '0 12px 24px rgba(15, 23, 42, 0.45)' | ||||||||
: '0 12px 24px rgba(15, 23, 42, 0.18)' | ||||||||
}) | ||||||||
}) | ||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dropdown menu is not fully keyboard accessible. It should allow navigation between items using the ArrowUp and ArrowDown keys. The current implementation only handles closing on 'Tab', which is insufficient for keyboard navigation within the menu.