Skip to content

Commit 8905b71

Browse files
FelixTJDietrichclaudegithub-actions[bot]
authored
feat(library): italic abstract classes/methods and lowercase UML class keywords (#799)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 30e4bdc commit 8905b71

51 files changed

Lines changed: 697 additions & 279 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Fix UML class-diagram notation and let methods be marked abstract.
6+
7+
- **Abstract is now UML-correct.** An abstract class shows an _italic_ name instead of the invalid `«Abstract»` keyword — UML has no `«abstract»` keyword; abstractness is a property, not a stereotype (UML 2.5.1 §9.2.4). The italic is a real shipped Inter italic face, so it renders the same in the editor and in PNG, PDF, and PowerPoint exports — no more `{abstract}` text fallback for a slant that used to vanish on export.
8+
- **Metaclass keywords are lowercase.** Interfaces and enumerations render `«interface»` / `«enumeration»` — the exact UML keyword spelling (2.5.1 Table C.1) — instead of the capitalized forms.
9+
- **New: abstract methods.** Mark any method abstract from the class editor (a per-method toggle) to render its signature in italics. Attributes are deliberately not offered the control: UML attributes cannot be abstract. Closes #105.
10+
- **A single "Class type" picker.** The class editor now sets the classifier with one dropdown — `Class`, `Abstract Class`, `Interface`, `Enumeration` — mirroring the four palette tiles and the other node/edge "type" selects, with a notation preview on each option. It replaces a checkbox-plus-toggle pair that could produce invalid states (an italic `«interface»`, or an abstract enumeration); those states are now unrepresentable, and any earlier diagram carrying one self-heals on load.
11+
12+
Diagrams saved earlier migrate automatically on load — a class stored with the old `"Abstract"` stereotype becomes `isAbstract`, `"Interface"` / `"Enumeration"` are lowercased to their keyword spelling, and a stray abstract modifier on a keyword is dropped.

.github/workflows/update-visual-baselines.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
run: pnpm --filter @tumaet/webapp run test:visual:update
5151

5252
- name: Commit and push refreshed baselines
53+
# Force bash: the container otherwise runs this under dash, which rejects
54+
# `set -o pipefail` and fails the step before it can push.
55+
shell: bash
5356
env:
5457
REF: ${{ github.event.inputs.ref }}
5558
run: |
@@ -58,9 +61,12 @@ jobs:
5861
git config --global --add safe.directory "$GITHUB_WORKSPACE"
5962
git config user.name "github-actions[bot]"
6063
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
61-
# Scope strictly to snapshot PNGs so a render bug can't smuggle other
62-
# tree changes back onto the branch.
63-
git add 'standalone/webapp/tests/**/*-snapshots/*.png'
64+
# Scope strictly to generated snapshot PNGs so a render bug can't
65+
# smuggle other tree changes back onto the branch. The how-to-use modal
66+
# assets are regenerated by the same run (howto-assets project) but land
67+
# under assets/images/, not a *-snapshots dir, so stage them explicitly.
68+
git add 'standalone/webapp/tests/**/*-snapshots/*.png' \
69+
'standalone/webapp/assets/images/how-to-use-*.png'
6470
if git diff --cached --quiet; then
6571
echo "Baselines already match — nothing to commit." >> "$GITHUB_STEP_SUMMARY"
6672
exit 0

library/lib/apollon-editor.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import {
1414
getSVG,
1515
getRenderedDiagramBounds,
1616
} from "./utils"
17+
// Internal (not re-exported through the public `./utils` barrel): brings any
18+
// incoming model onto the current schema (e.g. legacy class stereotypes) at
19+
// every hydration boundary, since editor load does NOT route through the public
20+
// `importDiagram`.
21+
import { normalizeModel } from "./utils/versionConverter"
1722
import { UMLDiagramType } from "./types"
1823
import { createDiagramStore, type DiagramStore } from "@/store/diagramStore"
1924
import { createMetadataStore, type MetadataStore } from "@/store/metadataStore"
@@ -161,12 +166,13 @@ export class ApollonEditor {
161166
.updateMetaData(diagramName, parseDiagramType(diagramType))
162167

163168
if (options?.model) {
164-
const nodes = options.model.nodes || []
165-
const edges = options.model.edges || []
166-
const assessments = options.model.assessments || {}
169+
const model = normalizeModel(options.model)
170+
const nodes = model.nodes || []
171+
const edges = model.edges || []
172+
const assessments = model.assessments || {}
167173
this.diagramStore.getState().setNodesAndEdges(nodes, edges)
168174
this.diagramStore.getState().setAssessments(assessments)
169-
this.diagramStore.getState().setInteractive(options.model.interactive)
175+
this.diagramStore.getState().setInteractive(model.interactive)
170176
}
171177

172178
if (options?.mode) {
@@ -498,6 +504,9 @@ export class ApollonEditor {
498504
model: Apollon.UMLModel,
499505
options?: Apollon.ExportOptions
500506
): Promise<Apollon.SVG> {
507+
// Off-screen render path bypasses the constructor, so normalize here too
508+
// (in place) — otherwise a legacy model exports with the old stereotypes.
509+
normalizeModel(model)
501510
const container = document.createElement("div")
502511
container.style.display = "flex"
503512
container.style.width = "4000px"
@@ -624,15 +633,20 @@ export class ApollonEditor {
624633
// Wait for webfonts to load before we measure: canvas text measurement
625634
// (used by the wrap layout) otherwise falls back to the generic-family
626635
// metrics and the exported SVG's wrap decisions would drift from the
627-
// on-screen render. Explicitly load Inter — `document.fonts.ready` alone
628-
// would not wait for the injected `@font-face` until a glyph requests it —
629-
// then await `ready` for any other pending faces. Best-effort throughout
630-
// (older browsers / jsdom may lack `document.fonts`).
636+
// on-screen render. Explicitly load each Inter face — `document.fonts.ready`
637+
// alone would not wait for an injected `@font-face` until a glyph requests
638+
// it — then await `ready` for any other pending faces. The italic faces
639+
// back abstract classes/methods; omitting them would measure/render that
640+
// text in a fallback family. Best-effort throughout (older browsers / jsdom
641+
// may lack `document.fonts`).
631642
if (typeof document !== "undefined" && document.fonts) {
632643
if (document.fonts.load) {
644+
const size = DEFAULT_FONT_SIZE
633645
await Promise.all([
634-
document.fonts.load(`400 ${DEFAULT_FONT_SIZE}px ${FONT_FAMILY}`),
635-
document.fonts.load(`700 ${DEFAULT_FONT_SIZE}px ${FONT_FAMILY}`),
646+
document.fonts.load(`400 ${size}px ${FONT_FAMILY}`),
647+
document.fonts.load(`700 ${size}px ${FONT_FAMILY}`),
648+
document.fonts.load(`italic 400 ${size}px ${FONT_FAMILY}`),
649+
document.fonts.load(`italic 700 ${size}px ${FONT_FAMILY}`),
636650
]).catch(() => {})
637651
}
638652
if (document.fonts.ready) {
@@ -906,7 +920,7 @@ export class ApollonEditor {
906920
const interactive = this.getInteractiveForSerialization()
907921
return {
908922
id: diagramId,
909-
version: "4.0.0",
923+
version: "4.1.0",
910924
title: diagramTitle,
911925
type: diagramType,
912926
nodes: nodes.map((node) => mapFromReactFlowNodeToApollonNode(node)),
@@ -916,7 +930,8 @@ export class ApollonEditor {
916930
}
917931
}
918932

919-
set model(model: Apollon.UMLModel) {
933+
set model(incoming: Apollon.UMLModel) {
934+
const model = normalizeModel(incoming)
920935
const { nodes, edges, assessments, interactive } = model
921936
// Every store action below routes its Yjs writes through the
922937
// shared `transactStore` helper that no-ops in preview mode, so
251 KB
Binary file not shown.
88.6 KB
Binary file not shown.
247 KB
Binary file not shown.
85.6 KB
Binary file not shown.

library/lib/components/popovers/classDiagram/ClassEditPopover.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,38 @@
1-
import { StereotypeButtonGroup, NodeStyleEditor } from "@/components"
1+
import { NodeStyleEditor } from "@/components"
22
import { useDiagramStore } from "@/store"
3-
import { ClassNodeProps } from "@/types"
3+
import { ClassNodeProps, ClassStereotype } from "@/types"
4+
import { LAYOUT } from "@/constants"
45
import { useShallow } from "zustand/shallow"
56
import { EditableAttributeList } from "./EditableAttributesList"
67
import { EditableMethodsList } from "./EditableMethodsList"
8+
import { ClassTypeSelect, type ClassKind } from "./ClassTypeSelect"
79
import { PopoverProps } from "../types"
810
import { PopoverLayout, PopoverSection } from "../PopoverLayout"
911

12+
// A kind maps to both fields at once. Writing both on every change keeps them
13+
// consistent: a plain class can't keep a stale `isAbstract`, and a keyword can't
14+
// carry the (invalid) abstract modifier.
15+
const KIND_TO_DATA: Record<
16+
ClassKind,
17+
{ stereotype?: ClassStereotype; isAbstract: boolean }
18+
> = {
19+
class: { stereotype: undefined, isAbstract: false },
20+
abstract: { stereotype: undefined, isAbstract: true },
21+
interface: { stereotype: ClassStereotype.Interface, isAbstract: false },
22+
enumeration: { stereotype: ClassStereotype.Enumeration, isAbstract: false },
23+
}
24+
25+
const hasKeyword = (kind: ClassKind) =>
26+
kind === "interface" || kind === "enumeration"
27+
28+
const kindOf = (data: ClassNodeProps): ClassKind => {
29+
// Stereotype wins, so a stray `isAbstract` beside a keyword never surfaces.
30+
if (data.stereotype === ClassStereotype.Enumeration) return "enumeration"
31+
if (data.stereotype === ClassStereotype.Interface) return "interface"
32+
if (data.isAbstract) return "abstract"
33+
return "class"
34+
}
35+
1036
export const ClassEditPopover: React.FC<PopoverProps> = ({ elementId }) => {
1137
const { nodes, setNodes } = useDiagramStore(
1238
useShallow((state) => ({
@@ -21,6 +47,7 @@ export const ClassEditPopover: React.FC<PopoverProps> = ({ elementId }) => {
2147
}
2248

2349
const nodeData = node.data as ClassNodeProps
50+
const currentKind = kindOf(nodeData)
2451

2552
const handleDataFieldUpdate = (key: string, value: string) => {
2653
setNodes((nodes) =>
@@ -39,18 +66,42 @@ export const ClassEditPopover: React.FC<PopoverProps> = ({ elementId }) => {
3966
)
4067
}
4168

69+
const setKind = (next: ClassKind) => {
70+
const mapped = KIND_TO_DATA[next]
71+
// A keyword adds a header line; the abstract modifier (italics) does not.
72+
const keywordLinePx =
73+
LAYOUT.DEFAULT_HEADER_HEIGHT_WITH_STEREOTYPE -
74+
LAYOUT.DEFAULT_HEADER_HEIGHT
75+
const delta =
76+
((hasKeyword(next) ? 1 : 0) - (hasKeyword(currentKind) ? 1 : 0)) *
77+
keywordLinePx
78+
setNodes((nodes) =>
79+
nodes.map((node) => {
80+
if (node.id !== elementId) return node
81+
const height = (node.height ?? 0) + delta
82+
return {
83+
...node,
84+
data: {
85+
...node.data,
86+
stereotype: mapped.stereotype,
87+
isAbstract: mapped.isAbstract,
88+
},
89+
height,
90+
measured: { ...node.measured, height },
91+
}
92+
})
93+
)
94+
}
95+
4296
return (
4397
<PopoverLayout title="Class">
4498
<NodeStyleEditor
4599
nodeData={nodeData}
46100
colorEditorLabel="class"
47101
handleDataFieldUpdate={handleDataFieldUpdate}
48102
/>
49-
<PopoverSection title="Stereotype" divider>
50-
<StereotypeButtonGroup
51-
nodeId={elementId}
52-
selectedStereotype={nodeData.stereotype}
53-
/>
103+
<PopoverSection divider>
104+
<ClassTypeSelect value={currentKind} onChange={setKind} />
54105
</PopoverSection>
55106
<PopoverSection divider>
56107
<EditableAttributeList nodeId={elementId} />
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useMemo } from "react"
2+
import { Select, type SelectOption } from "@/components/ui"
3+
import { ClassStereotype } from "@/types"
4+
import { stereotypeLabel } from "@/utils"
5+
6+
/**
7+
* The four class classifier kinds. They map onto `{stereotype, isAbstract}`; the
8+
* two invalid combinations (abstract enumeration, redundant abstract interface)
9+
* are unrepresentable here by design.
10+
*/
11+
export type ClassKind = "class" | "abstract" | "interface" | "enumeration"
12+
13+
type KindDescriptor = {
14+
value: ClassKind
15+
label: string
16+
/** Metaclass keyword shown as a `«…»` line, mirroring the header. */
17+
keyword?: ClassStereotype
18+
/** Abstract classifiers render their name in italics (UML 2.5.1 §9.2.4). */
19+
italic?: boolean
20+
}
21+
22+
const KINDS: readonly KindDescriptor[] = [
23+
{ value: "class", label: "Class" },
24+
{ value: "abstract", label: "Abstract Class", italic: true },
25+
{
26+
value: "interface",
27+
label: "Interface",
28+
keyword: ClassStereotype.Interface,
29+
},
30+
{
31+
value: "enumeration",
32+
label: "Enumeration",
33+
keyword: ClassStereotype.Enumeration,
34+
},
35+
]
36+
37+
// Each row previews the notation: a `«keyword»` chip and/or an italic name.
38+
const KindRow = ({ label, keyword, italic }: KindDescriptor) => (
39+
<span style={{ alignItems: "baseline", display: "flex", gap: 8 }}>
40+
{keyword && (
41+
<span style={{ fontSize: "0.85em", opacity: 0.6 }}>
42+
{stereotypeLabel(keyword)}
43+
</span>
44+
)}
45+
<span style={{ fontStyle: italic ? "italic" : "normal" }}>{label}</span>
46+
</span>
47+
)
48+
49+
interface ClassTypeSelectProps {
50+
value: ClassKind
51+
onChange: (value: ClassKind) => void
52+
}
53+
54+
/**
55+
* Single "Class type" picker; its value maps to `{stereotype, isAbstract}` in
56+
* `ClassEditPopover`. A class is always exactly one kind, so there is no empty
57+
* state.
58+
*/
59+
export const ClassTypeSelect = ({ value, onChange }: ClassTypeSelectProps) => {
60+
const options: SelectOption[] = useMemo(
61+
() =>
62+
KINDS.map((kind) => ({
63+
value: kind.value,
64+
label: kind.label,
65+
renderOption: () => <KindRow {...kind} />,
66+
renderValue: () => <KindRow {...kind} />,
67+
})),
68+
[]
69+
)
70+
71+
return (
72+
<Select
73+
label="Class type"
74+
aria-label="Class type"
75+
value={value}
76+
options={options}
77+
onChange={(next) => onChange(next as ClassKind)}
78+
/>
79+
)
80+
}

library/lib/components/popovers/classDiagram/EditableMethodsList.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import React, { useState, KeyboardEvent, ChangeEvent } from "react"
2-
import { GripVertical, Plus, Trash2 } from "lucide-react"
2+
import { GripVertical, Italic, Plus, Trash2 } from "lucide-react"
33
import { IconButton, TextField, Typography } from "@/components/ui"
44
import { NodeStyleEditor } from "@/components/styleEditor"
55
import { generateUUID } from "@/utils"
66
import { useDiagramStore } from "@/store"
77
import { useShallow } from "zustand/shallow"
8-
import { ClassNodeProps } from "@/types"
8+
import { ClassNodeElement, ClassNodeProps } from "@/types"
99
import {
1010
DndContext,
1111
closestCenter,
@@ -30,15 +30,17 @@ interface Props {
3030

3131
interface SortableMethodRowProps {
3232
id: string
33-
item: { id: string; name: string }
33+
item: ClassNodeElement
3434
onMethodChange: (id: string, key: string, value: string) => void
35+
onToggleAbstract: (id: string) => void
3536
onDelete: (id: string) => void
3637
}
3738

3839
const SortableMethodRow: React.FC<SortableMethodRowProps> = ({
3940
id,
4041
item,
4142
onMethodChange,
43+
onToggleAbstract,
4244
onDelete,
4345
}) => {
4446
const {
@@ -93,6 +95,21 @@ const SortableMethodRow: React.FC<SortableMethodRowProps> = ({
9395
onMethodChange(item.id, key, value)
9496
}
9597
sideElements={[
98+
<IconButton
99+
key={`abstract_${item.id}`}
100+
ariaLabel={
101+
item.isAbstract
102+
? "Unmark method as abstract"
103+
: "Mark method as abstract"
104+
}
105+
tooltip="Abstract method (italic)"
106+
aria-pressed={!!item.isAbstract}
107+
data-state={item.isAbstract ? "on" : "off"}
108+
className="apollon-abstract-toggle"
109+
onClick={() => onToggleAbstract(item.id)}
110+
>
111+
<Italic width={16} height={16} aria-hidden="true" />
112+
</IconButton>,
96113
<IconButton
97114
key={`delete_${item.id}`}
98115
ariaLabel="Delete method"
@@ -136,6 +153,14 @@ export const EditableMethodsList: React.FC<Props> = ({ nodeId }) => {
136153
)
137154
}
138155

156+
const handleToggleAbstract = (id: string) => {
157+
patchMethods(
158+
methods.map((item) =>
159+
item.id === id ? { ...item, isAbstract: !item.isAbstract } : item
160+
)
161+
)
162+
}
163+
139164
const handleItemDelete = (id: string) => {
140165
setNodes((nodes) =>
141166
nodes.map((node) => {
@@ -211,6 +236,7 @@ export const EditableMethodsList: React.FC<Props> = ({ nodeId }) => {
211236
id={item.id}
212237
item={item}
213238
onMethodChange={handleMethodChange}
239+
onToggleAbstract={handleToggleAbstract}
214240
onDelete={handleItemDelete}
215241
/>
216242
))}

0 commit comments

Comments
 (0)