Skip to content

Commit cb709db

Browse files
authored
Merge pull request #25 from sraphaz/feat/drawio-export
feat: DrawIO export (CLI, Playground, round-trip) + CodeRabbit fixes
2 parents d82b153 + ef1b871 commit cb709db

File tree

8 files changed

+63
-32
lines changed

8 files changed

+63
-32
lines changed

apps/playground/src/components/drawio/DrawioContextMenu.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function DrawioContextMenu({
4040
onExportAllViews={actions.handleExportAllViews}
4141
canExport={actions.canExport}
4242
canExportAllViews={actions.canExportAllViews}
43+
exporting={actions.exporting}
4344
/>
4445
{children(actions.openMenu)}
4546
</>

apps/playground/src/components/drawio/DrawioContextMenuDropdown.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type DrawioContextMenuDropdownProps = {
1313
onExportAllViews?: () => void
1414
canExport: boolean
1515
canExportAllViews?: boolean
16+
exporting?: boolean
1617
}
1718

1819
/**
@@ -27,6 +28,7 @@ export function DrawioContextMenuDropdown({
2728
onExportAllViews,
2829
canExport,
2930
canExportAllViews = false,
31+
exporting = false,
3032
}: DrawioContextMenuDropdownProps) {
3133
return (
3234
<Menu
@@ -70,7 +72,7 @@ export function DrawioContextMenuDropdown({
7072
<Menu.Item
7173
leftSection={<IconFileExport size={16} />}
7274
onClick={onExportAllViews}
73-
disabled={!canExportAllViews}
75+
disabled={!canExportAllViews || exporting}
7476
title="Export all views as one .drawio file (one tab per view). Use this to get all diagram tabs (e.g. Landscape + Our SaaS).">
7577
Export all…
7678
</Menu.Item>

apps/playground/src/components/drawio/DrawioContextMenuProvider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export function DrawioContextMenuProvider({
127127
onExportAllViews={actions.handleExportAllViews}
128128
canExport={actions.canExport}
129129
canExportAllViews={actions.canExportAllViews}
130+
exporting={actions.exporting}
130131
/>
131132
{children}
132133
</DrawioContextMenuContext.Provider>

apps/playground/src/components/drawio/useDrawioContextMenuActions.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export function useDrawioContextMenuActions({
207207
}, [likec4model, viewStates])
208208
const [opened, { open, close }] = useDisclosure(false)
209209
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
210+
const [exporting, setExporting] = useState(false)
210211

211212
const openMenu = useCallback((event: React.MouseEvent | MouseEvent) => {
212213
event.preventDefault()
@@ -241,28 +242,33 @@ export function useDrawioContextMenuActions({
241242
const handleExportAllViews = useCallback(async () => {
242243
close()
243244
if (!likec4model) return
244-
const viewIdsInModel = [...likec4model.views()].map(vm => vm.$view.id)
245-
const viewModels = await collectViewModelsForExportAll({
246-
viewIdsInModel,
247-
allViewModelsFromState,
248-
likec4model,
249-
viewStates,
250-
getLayoutedModel,
251-
layoutViews,
252-
...(onExportError != null && { onExportError }),
253-
})
254-
if (viewModels.length === 0) return
245+
if (exporting) return
246+
setExporting(true)
255247
try {
248+
const viewIdsInModel = [...likec4model.views()].map(vm => vm.$view.id)
249+
const viewModels = await collectViewModelsForExportAll({
250+
viewIdsInModel,
251+
allViewModelsFromState,
252+
likec4model,
253+
viewStates,
254+
getLayoutedModel,
255+
layoutViews,
256+
...(onExportError != null && { onExportError }),
257+
})
258+
if (viewModels.length === 0) return
256259
const sourceContent = getSourceContent?.()
257260
const viewIds = viewModels.map(vm => vm.$view.id)
258261
const optionsByViewId = buildDrawioExportOptionsForViews(viewIds, sourceContent)
259262
const xml = generateDrawioMulti(viewModels, optionsByViewId)
260263
downloadDrawioBlob(xml, DEFAULT_DRAWIO_ALL_FILENAME)
261264
} catch (err) {
262265
reportExportError('DrawIO export all views failed', err, onExportError)
266+
} finally {
267+
setExporting(false)
263268
}
264269
}, [
265270
close,
271+
exporting,
266272
allViewModelsFromState,
267273
getSourceContent,
268274
getLayoutedModel,
@@ -286,5 +292,6 @@ export function useDrawioContextMenuActions({
286292
close,
287293
canExport: diagram != null,
288294
canExportAllViews,
295+
exporting,
289296
}
290297
}

apps/playground/src/monaco/LanguageClientSync.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ export function LanguageClientSync({
140140
}
141141
}),
142142
)
143+
const failedCount = viewIds.length - Object.keys(out).length
144+
if (failedCount > 0) {
145+
logger.warn(`${failedCount}/${viewIds.length} views failed to layout`)
146+
}
143147
return out
144148
},
145149
})

packages/generators/src/drawio/generate-drawio.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -268,16 +268,16 @@ function getElementColors(
268268
const values = getThemeColorValues(viewmodel, color, 'primary')
269269
const elementColors = values.elements
270270
return {
271-
fill: elementColors.fill,
272-
stroke: elementColors.stroke,
273-
font: (elementColors.hiContrast ?? elementColors.stroke) as string,
271+
fill: String(elementColors.fill ?? DEFAULT_NODE_FILL_HEX),
272+
stroke: String(elementColors.stroke ?? DEFAULT_NODE_STROKE_HEX),
273+
font: String(elementColors.hiContrast ?? elementColors.stroke ?? DEFAULT_NODE_FONT_HEX),
274274
}
275275
}
276276

277277
/** Edge stroke (line) color from theme RelationshipColorValues.line. */
278278
function getEdgeStrokeColor(viewmodel: DrawioViewModelLike, color: string | undefined): string {
279279
const values = getThemeColorValues(viewmodel, color ?? 'gray', 'gray')
280-
return values.relationships.line as string
280+
return String(values.relationships?.line ?? DEFAULT_NODE_FONT_HEX)
281281
}
282282

283283
/** Edge label font and background from theme (RelationshipColorValues.label, labelBg) for readable connector text. */
@@ -288,8 +288,8 @@ function getEdgeLabelColors(
288288
const values = getThemeColorValues(viewmodel, color ?? 'gray', 'gray')
289289
const rel = values.relationships as RelationshipColorValues
290290
return {
291-
font: (rel.label ?? rel.line) as string,
292-
background: (rel.labelBg ?? '#ffffff') as string,
291+
font: String(rel?.label ?? rel?.line ?? DEFAULT_NODE_FONT_HEX),
292+
background: String(rel?.labelBg ?? '#ffffff'),
293293
}
294294
}
295295

@@ -1158,10 +1158,12 @@ function generateDiagramContent(
11581158
const { sortedNodes, defaultParentId, rootId, canvasWidth, canvasHeight } = layout
11591159

11601160
const nodeIds = new Map<NodeId, string>()
1161+
// cellId stays below CONTAINER_TITLE_CELL_ID_START (10000); container title IDs use separate range
11611162
let cellId = 2
11621163
const getCellId = (nodeId: NodeId): string => {
11631164
let id = nodeIds.get(nodeId)
11641165
if (!id) {
1166+
if (cellId >= CONTAINER_TITLE_CELL_ID_START) throw new Error('DrawIO cell ID range exhausted')
11651167
id = String(cellId++)
11661168
nodeIds.set(nodeId, id)
11671169
}
@@ -1193,6 +1195,7 @@ function generateDiagramContent(
11931195
}
11941196

11951197
for (const edge of edges) {
1198+
if (cellId >= CONTAINER_TITLE_CELL_ID_START) throw new Error('DrawIO cell ID range exhausted')
11961199
const edgeId = String(cellId++)
11971200
edgeCells.push(
11981201
buildEdgeCellXml(edge, layout, options, viewmodel, getCellId, edgeId),

packages/generators/src/drawio/parse-drawio.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ function getDecodedStyle(styleMap: Map<string, string>, key: string): string | u
179179
}
180180
}
181181

182-
/** Parse DrawIO style string (semicolon-separated key=value) into a map. */
182+
/**
183+
* Parse DrawIO style string (semicolon-separated key=value) into a map.
184+
* Entries with empty values are intentionally dropped (meaningful style values are non-empty).
185+
*/
183186
function parseStyle(style: string | undefined): Map<string, string> {
184187
const map = new Map<string, string>()
185188
if (!style) return map
@@ -1646,7 +1649,12 @@ views {
16461649
}
16471650
`
16481651
}
1649-
if (diagrams.length === 1) return parseDrawioToLikeC4(xml)
1652+
if (diagrams.length === 1) {
1653+
const d = diagrams[0]!
1654+
const cells = parseDrawioXml(d.content)
1655+
const state = buildSingleDiagramState(cells, d.name)
1656+
return emitLikeC4SourceFromSingleState(state)
1657+
}
16501658

16511659
const states: DiagramState[] = []
16521660
for (const d of diagrams) {

packages/likec4/src/cli/export/drawio/handler.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,29 @@ async function readWorkspaceSourceContent(
9595
})
9696
for (const e of entries) {
9797
const full = join(dir, e.name)
98-
// Note: symlinks to directories are not followed (isDirectory() is false for symlinks).
99-
if (e.isDirectory()) {
100-
if (!ROUNDTRIP_IGNORED_DIRS.has(e.name)) await walk(full, depth + 1)
101-
} else if (e.isSymbolicLink()) {
102-
const st = await stat(full).catch(() => null)
103-
if (st?.isFile() && isSourceFile(e.name)) {
98+
switch (true) {
99+
case e.isDirectory():
100+
if (!ROUNDTRIP_IGNORED_DIRS.has(e.name)) await walk(full, depth + 1)
101+
break
102+
case e.isSymbolicLink(): {
103+
const st = await stat(full).catch(() => null)
104+
if (st?.isFile() && isSourceFile(e.name)) {
105+
const content = await readFile(full, 'utf-8').catch(err => {
106+
if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
107+
return ''
108+
})
109+
if (content) chunks.push(content)
110+
}
111+
break
112+
}
113+
case e.isFile() && isSourceFile(e.name): {
104114
const content = await readFile(full, 'utf-8').catch(err => {
105115
if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
106116
return ''
107117
})
108118
if (content) chunks.push(content)
119+
break
109120
}
110-
} else if (e.isFile() && isSourceFile(e.name)) {
111-
const content = await readFile(full, 'utf-8').catch(err => {
112-
if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
113-
return ''
114-
})
115-
if (content) chunks.push(content)
116121
}
117122
}
118123
}

0 commit comments

Comments
 (0)