Skip to content

Commit 6dee0c7

Browse files
authored
Merge pull request #36 from openpatch/copilot/limit-font-size-and-zindex
Add font size presets, zIndex layering, and image captions with markdown links
2 parents 1d5bf34 + 6cb5b1b commit 6dee0c7

12 files changed

Lines changed: 3832 additions & 4353 deletions

packages/learningmap/src/EditorDrawerImageContent.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export function EditorDrawerImageContent({ localNode, handleFieldChange, languag
3434
onChange={handleFileChange}
3535
/>
3636
</div>
37+
<div className="form-group">
38+
<label>{t.caption}</label>
39+
<input
40+
type="text"
41+
value={localNode.data.caption || ""}
42+
onChange={(e) => handleFieldChange("caption", e.target.value)}
43+
placeholder={t.placeholderImageCaption}
44+
/>
45+
</div>
3746
{localNode.data.data && (
3847
<div style={{ marginTop: 16 }}>
3948
<label>Preview:</label>

packages/learningmap/src/EditorDrawerTaskContent.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Node } from "@xyflow/react";
22
import { Plus, Trash2 } from "lucide-react";
33
import { NodeData, Resource } from "./types";
44
import { getTranslations } from "./translations";
5+
import { FONT_SIZE_VALUES, getFontSizeOption, FontSizeOption } from "./fontSizes";
56

67
interface Props {
78
localNode: Node<NodeData>;
@@ -94,15 +95,32 @@ export function EditorDrawerTaskContent({
9495
/>
9596
</div>
9697
<div className="form-group">
97-
<label>Font Size (px)</label>
98-
<input
99-
type="number"
100-
value={localNode.data.fontSize || 14}
101-
onChange={(e) => handleFieldChange("fontSize", parseInt(e.target.value) || 14)}
102-
placeholder="14"
103-
min="8"
104-
max="72"
105-
/>
98+
<label>{t.fontSize}</label>
99+
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
100+
{(["S", "M", "L", "XL"] as FontSizeOption[]).map((size) => {
101+
const isSelected = getFontSizeOption(localNode.data.fontSize) === size;
102+
return (
103+
<button
104+
key={size}
105+
type="button"
106+
onClick={() => handleFieldChange("fontSize", FONT_SIZE_VALUES[size])}
107+
style={{
108+
width: 40,
109+
height: 40,
110+
borderRadius: 6,
111+
border: isSelected ? "2px solid #3b82f6" : "1px solid #d1d5db",
112+
backgroundColor: isSelected ? "#eff6ff" : "#ffffff",
113+
color: isSelected ? "#3b82f6" : "#374151",
114+
cursor: "pointer",
115+
fontWeight: isSelected ? "bold" : "normal",
116+
fontSize: "14px",
117+
}}
118+
>
119+
{size}
120+
</button>
121+
);
122+
})}
123+
</div>
106124
</div>
107125
<div className="form-group">
108126
<label>{t.summary}</label>

packages/learningmap/src/EditorToolbar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Node, useReactFlow } from "@xyflow/react";
99
import { NodeData } from "./types";
1010
import { useJsonStore } from "./useJsonStore";
1111
import { useFileOperations } from "./useFileOperations";
12+
import { getZIndexForNodeType } from "./zIndexHelper";
1213

1314
interface EditorToolbarProps {
1415
defaultLanguage?: string;
@@ -56,6 +57,7 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
5657
id: `node-${Date.now()}`,
5758
type,
5859
position,
60+
zIndex: getZIndexForNodeType(type),
5961
data: {
6062
label: type === "task" ? t.newTask : type === "topic" ? t.newTopic : type,
6163
state: "unlocked",

packages/learningmap/src/editorStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Connection,
1717
} from "@xyflow/react";
1818
import { NodeData, RoadmapData, Settings } from "./types";
19+
import { getZIndexForNodeType } from "./zIndexHelper";
1920

2021
// Note: This is a global store for the editor. Typically only one editor instance is active at a time.
2122
// If you need multiple independent editor instances, consider creating store instances per component or using context.
@@ -370,6 +371,8 @@ export const useEditorStore = create<EditorState>()(
370371
...n,
371372
draggable: true,
372373
className: n.data.color ? n.data.color : n.className,
374+
// Ensure zIndex is set based on node type if not already present
375+
zIndex: n.zIndex !== undefined ? n.zIndex : getZIndexForNodeType(n.type),
373376
data: { ...n.data },
374377
}));
375378

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Font size constants for node labels
2+
3+
export type FontSizeOption = "S" | "M" | "L" | "XL";
4+
5+
export const FONT_SIZE_VALUES: Record<FontSizeOption, number> = {
6+
S: 10,
7+
M: 14,
8+
L: 18,
9+
XL: 24,
10+
};
11+
12+
export const DEFAULT_FONT_SIZE: FontSizeOption = "M";
13+
14+
// Thresholds for mapping numeric values to font size options
15+
const THRESHOLD_S_TO_M = 10;
16+
const THRESHOLD_M_TO_L = 14;
17+
const THRESHOLD_L_TO_XL = 18;
18+
19+
// Helper function to map numeric value to closest font size option
20+
function mapNumericToOption(value: number): FontSizeOption {
21+
if (value <= THRESHOLD_S_TO_M) return "S";
22+
if (value <= THRESHOLD_M_TO_L) return "M";
23+
if (value <= THRESHOLD_L_TO_XL) return "L";
24+
return "XL";
25+
}
26+
27+
export function getFontSizeValue(size?: number | FontSizeOption): number {
28+
if (typeof size === "number") {
29+
// Convert old numeric values to closest size
30+
return FONT_SIZE_VALUES[mapNumericToOption(size)];
31+
}
32+
if (size && size in FONT_SIZE_VALUES) {
33+
return FONT_SIZE_VALUES[size as FontSizeOption];
34+
}
35+
return FONT_SIZE_VALUES[DEFAULT_FONT_SIZE];
36+
}
37+
38+
export function getFontSizeOption(value?: number): FontSizeOption {
39+
if (!value) return DEFAULT_FONT_SIZE;
40+
return mapNumericToOption(value);
41+
}

packages/learningmap/src/nodes/ImageNode.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,109 @@
11
import { Node, NodeResizer } from "@xyflow/react";
22
import { ImageNodeData } from "../types";
33

4+
// Normalize and validate URL to prevent XSS attacks
5+
function normalizeAndValidateUrl(url: string): string | null {
6+
// Trim whitespace
7+
url = url.trim();
8+
9+
// If URL doesn't start with a protocol, prepend https://
10+
if (!url.match(/^[a-z]+:\/\//i)) {
11+
url = 'https://' + url;
12+
}
13+
14+
try {
15+
const parsedUrl = new URL(url);
16+
// Only allow http, https, and mailto protocols
17+
if (['http:', 'https:', 'mailto:'].includes(parsedUrl.protocol)) {
18+
return parsedUrl.href;
19+
}
20+
return null;
21+
} catch {
22+
return null;
23+
}
24+
}
25+
26+
// Simple markdown link parser for captions - supports [text](url) and bare URLs
27+
function parseMarkdownLinks(text: string): React.ReactNode[] {
28+
const parts: React.ReactNode[] = [];
29+
30+
// Combined regex for markdown links [text](url) and bare URLs
31+
// Matches markdown links first, then bare URLs (starting with http:// or https://)
32+
const combinedRegex = /\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g;
33+
34+
const matches = Array.from(text.matchAll(combinedRegex));
35+
let lastIndex = 0;
36+
37+
matches.forEach((match, index) => {
38+
// Add text before the link
39+
if (match.index !== undefined && match.index > lastIndex) {
40+
parts.push(text.substring(lastIndex, match.index));
41+
}
42+
43+
if (match[1] && match[2]) {
44+
// Markdown link [text](url)
45+
const linkText = match[1];
46+
const linkUrl = match[2];
47+
48+
const normalizedUrl = normalizeAndValidateUrl(linkUrl);
49+
if (normalizedUrl) {
50+
parts.push(
51+
<a key={index} href={normalizedUrl} target="_blank" rel="noopener noreferrer" style={{ color: "#3b82f6", textDecoration: "underline" }}>
52+
{linkText}
53+
</a>
54+
);
55+
} else {
56+
// If URL is invalid, just show the text
57+
parts.push(`[${linkText}](${linkUrl})`);
58+
}
59+
} else if (match[3]) {
60+
// Bare URL
61+
const url = match[3];
62+
const normalizedUrl = normalizeAndValidateUrl(url);
63+
if (normalizedUrl) {
64+
parts.push(
65+
<a key={index} href={normalizedUrl} target="_blank" rel="noopener noreferrer" style={{ color: "#3b82f6", textDecoration: "underline" }}>
66+
{url}
67+
</a>
68+
);
69+
} else {
70+
parts.push(url);
71+
}
72+
}
73+
74+
if (match.index !== undefined) {
75+
lastIndex = match.index + match[0].length;
76+
}
77+
});
78+
79+
// Add remaining text
80+
if (lastIndex < text.length) {
81+
parts.push(text.substring(lastIndex));
82+
}
83+
84+
return parts.length > 0 ? parts : [text];
85+
}
86+
487
export const ImageNode = ({ data, selected }: Node<ImageNodeData>) => {
588
return (
689
<>
790
{data.data ? (
891
<>
992
<NodeResizer isVisible={selected} keepAspectRatio />
10-
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", overflow: "hidden" }}>
93+
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", overflow: "hidden", gap: "4px" }}>
1194
<img
1295
src={data.data}
1396
style={{
1497
maxWidth: "100%",
15-
maxHeight: "100%",
98+
maxHeight: data.caption ? "calc(100% - 24px)" : "100%",
99+
objectFit: "contain",
16100
}}
17101
/>
102+
{data.caption && (
103+
<div style={{ fontSize: "11px", color: "#6b7280", textAlign: "center", padding: "0 4px", lineHeight: "1.3" }}>
104+
{parseMarkdownLinks(data.caption)}
105+
</div>
106+
)}
18107
</div>
19108
</>
20109
) : (

packages/learningmap/src/nodes/TaskNode.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Handle, Node, NodeResizer, Position } from "@xyflow/react";
22
import { NodeData } from "../types";
33
import { CircleCheck } from "lucide-react";
4+
import { getFontSizeValue } from "../fontSizes";
45

56
export const TaskNode = ({ data, selected, isConnectable, ...props }: Node<NodeData>) => {
67
return (
78
<>
89
{isConnectable && <NodeResizer isVisible={selected} />}
910
<CircleCheck className="icon" />
1011
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", textAlign: "center" }}>
11-
<div style={{ fontWeight: 600, fontSize: data.fontSize ? `${data.fontSize}px` : "14px" }}>
12+
<div style={{ fontWeight: 600, fontSize: `${getFontSizeValue(data.fontSize)}px` }}>
1213
{data.label || "Untitled"}
1314
</div>
1415
{data.summary && (

packages/learningmap/src/nodes/TopicNode.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Handle, Node, NodeResizer, Position } from "@xyflow/react";
22
import { NodeData } from "../types";
33
import StarCircle from "../icons/StarCircle";
4+
import { getFontSizeValue } from "../fontSizes";
45

56
export const TopicNode = ({ data, selected, isConnectable }: Node<NodeData>) => {
67
return (
78
<>
89
{isConnectable && <NodeResizer isVisible={selected} />}
910
{data.state === "mastered" && <StarCircle className="icon" />}
1011
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", textAlign: "center" }}>
11-
<div style={{ fontWeight: 600, fontSize: data.fontSize ? `${data.fontSize}px` : "14px" }}>
12+
<div style={{ fontWeight: 600, fontSize: `${getFontSizeValue(data.fontSize)}px` }}>
1213
{data.label || "Untitled"}
1314
</div>
1415
{data.summary && (

packages/learningmap/src/translations.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ export interface Translations {
203203
welcomeOpenFile: string;
204204
welcomeAddTopic: string;
205205
welcomeHelp: string;
206+
207+
// Font size options
208+
fontSizeSmall: string;
209+
fontSizeMedium: string;
210+
fontSizeLarge: string;
211+
fontSizeXLarge: string;
212+
213+
// Image caption
214+
caption: string;
215+
placeholderImageCaption: string;
206216
}
207217

208218
const en: Translations = {
@@ -414,6 +424,16 @@ const en: Translations = {
414424
welcomeOpenFile: "Open File",
415425
welcomeAddTopic: "Add Topic",
416426
welcomeHelp: "Help",
427+
428+
// Font size options
429+
fontSizeSmall: "Small",
430+
fontSizeMedium: "Medium",
431+
fontSizeLarge: "Large",
432+
fontSizeXLarge: "Extra Large",
433+
434+
// Image caption
435+
caption: "Caption",
436+
placeholderImageCaption: "Add caption (supports [markdown links](url))",
417437
};
418438

419439
const de: Translations = {
@@ -628,6 +648,16 @@ const de: Translations = {
628648
welcomeOpenFile: "Datei öffnen",
629649
welcomeAddTopic: "Thema hinzufügen",
630650
welcomeHelp: "Hilfe",
651+
652+
// Font size options
653+
fontSizeSmall: "Klein",
654+
fontSizeMedium: "Mittel",
655+
fontSizeLarge: "Groß",
656+
fontSizeXLarge: "Extra Groß",
657+
658+
// Image caption
659+
caption: "Bildunterschrift",
660+
placeholderImageCaption: "Bildunterschrift hinzufügen (unterstützt [Markdown-Links](url))",
631661
};
632662

633663
export const translations: Record<string, Translations> = {

packages/learningmap/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface NodeData {
3939

4040
export interface ImageNodeData {
4141
data?: string; // base64 encoded image
42+
caption?: string; // Caption with markdown support for links
4243
}
4344

4445
export interface TextNodeData {

0 commit comments

Comments
 (0)