diff --git a/frontend/src/components/MarkdownFormatToolbar.tsx b/frontend/src/components/MarkdownFormatToolbar.tsx
new file mode 100644
index 0000000..d9ca1d0
--- /dev/null
+++ b/frontend/src/components/MarkdownFormatToolbar.tsx
@@ -0,0 +1,160 @@
+/**
+ * MarkdownFormatToolbar — contextual toolbar for selected markdown cards.
+ * Shows background color picker, width presets, and accent color picker.
+ */
+
+import React, { useState, useEffect } from 'react';
+
+interface MarkdownFormatToolbarProps {
+ x: number;
+ y: number;
+ bgColor: string;
+ accentColor: string;
+ width: number;
+ onBgColorChange: (color: string) => void;
+ onAccentColorChange: (color: string) => void;
+ onWidthChange: (width: number) => void;
+}
+
+const PRESET_COLORS = [
+ '#232336', '#1e1e2e', '#2a2a3a', '#1a2332', '#2a1a2a',
+ '#1a2a1a', '#2a2a1a', '#333333', '#1a1a1a', '#3a2a42',
+];
+
+const ACCENT_COLORS = [
+ '#7950f2', '#4dabf7', '#69db7c', '#ffd43b', '#ff6b6b',
+ '#e64980', '#ffa94d', '#868e96', '#ffffff', '#f783ac',
+];
+
+const WIDTH_PRESETS = [
+ { label: 'S', value: 300 },
+ { label: 'M', value: 450 },
+ { label: 'L', value: 650 },
+];
+
+export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) {
+ const { x, y, bgColor, accentColor, width, onBgColorChange, onAccentColorChange, onWidthChange } = props;
+ const [showBgPicker, setShowBgPicker] = useState(false);
+ const [showAccentPicker, setShowAccentPicker] = useState(false);
+
+ useEffect(() => {
+ const onDown = () => { setShowBgPicker(false); setShowAccentPicker(false); };
+ window.addEventListener('pointerdown', onDown);
+ return () => window.removeEventListener('pointerdown', onDown);
+ }, []);
+
+ const btnStyle: React.CSSProperties = {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '26px', background: 'transparent', border: 'none', borderRadius: '5px',
+ color: '#999', cursor: 'pointer', padding: '0 6px', fontSize: '11px',
+ fontFamily: 'system-ui, sans-serif', whiteSpace: 'nowrap', transition: 'all 0.1s',
+ };
+
+ return (
+
e.stopPropagation()}
+ >
+ {/* Background color */}
+
+
+ {showBgPicker && (
+
e.stopPropagation()}
+ >
+
+ {PRESET_COLORS.map((c) => (
+
+
+ )}
+
+
+
+
+ {/* Width presets */}
+
+ {WIDTH_PRESETS.map((p) => (
+
+ ))}
+
+
+
+
+ {/* Accent color */}
+
+
+ {showAccentPicker && (
+
e.stopPropagation()}
+ >
+
+ {ACCENT_COLORS.map((c) => (
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index 8828c4a..e03503a 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -44,6 +44,7 @@ import * as ops from '../canvas/operations';
import ReactDOM from 'react-dom';
import MarkdownReadView from '../components/MarkdownReadView';
import PasteChoicePopup from '../components/PasteChoicePopup';
+import MarkdownFormatToolbar from '../components/MarkdownFormatToolbar';
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
// Hooks
@@ -133,6 +134,7 @@ export default function Editor({ isPublicView }: EditorProps) {
>(null);
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
const [pastePopup, setPastePopup] = useState<{ x: number; y: number; text: string; html: string; hasImage: boolean } | null>(null);
+ const [mdToolbar, setMdToolbar] = useState<{ x: number; y: number; item: SceneItem } | null>(null);
const [showMinimap, setShowMinimap] = useState(true);
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 },
@@ -708,6 +710,17 @@ export default function Editor({ isPublicView }: EditorProps) {
setTextToolbar(null);
}
+ // Markdown format toolbar: show when exactly one markdown card is selected
+ const mdItems = items.filter((it) => it.type === 'markdown');
+ if (mdItems.length === 1 && items.length === 1) {
+ const b = getItemWorldBounds(mdItems[0]);
+ const screenTL = vp.toScreen(b.x, b.y);
+ const screenTR = vp.toScreen(b.x + b.w, b.y);
+ setMdToolbar({ x: (screenTL.x + screenTR.x) / 2, y: screenTL.y, item: mdItems[0] });
+ } else {
+ setMdToolbar(null);
+ }
+
if (items.length < 2) { setSelToolbar(null); } else {
// Compute world bounding box of selection
let minX = Infinity, minY = Infinity, maxX = -Infinity;
@@ -1066,6 +1079,31 @@ export default function Editor({ isPublicView }: EditorProps) {
/>
)}
+ {/* Markdown format toolbar */}
+ {mdToolbar && activeTool === ToolType.SELECT && !contextMenu && !editingMdId && (
+ {
+ (mdToolbar.item.data as MarkdownObject).bgColor = color;
+ onCanvasChange([mdToolbar.item.id]);
+ }}
+ onAccentColorChange={(color) => {
+ (mdToolbar.item.data as MarkdownObject).accentColor = color;
+ onCanvasChange([mdToolbar.item.id]);
+ }}
+ onWidthChange={(w) => {
+ mdToolbar.item.data.w = w;
+ onCanvasChange([mdToolbar.item.id]);
+ mdOverlay?.measureHeight(mdToolbar.item.id);
+ selectionRef.current?.transformBox.update([mdToolbar.item]);
+ }}
+ />
+ )}
+
{/* Video controls (floating, below selected video) */}
{videoCtrl && activeTool === ToolType.SELECT && !contextMenu && (