`,
+ ` `,
+ ` ${escapedMarkdown}`,
+ `
`,
+ ``,
+ ].join("\n");
+}
+
/**
* Escape special XML characters in strings.
*/
diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts
index 8d7298b..820f3f3 100644
--- a/packages/core/src/geom.ts
+++ b/packages/core/src/geom.ts
@@ -6,6 +6,7 @@ import type {
BrushConfig,
EllipseShape,
LineShape,
+ MarkdownShape,
RectShape,
ShapeRecord,
StrokePoint,
@@ -44,6 +45,9 @@ export function shapeBounds(shape: ShapeRecord): Box2 {
case "stroke": {
return strokeBounds(shape);
}
+ case "markdown": {
+ return markdownBounds(shape);
+ }
}
}
@@ -139,6 +143,26 @@ function textBounds(shape: TextShape): Box2 {
return Box2Ops.fromPoints(translatedCorners);
}
+/**
+ * Get bounds for a markdown block shape
+ */
+function markdownBounds(shape: MarkdownShape): Box2 {
+ const { w, h, fontSize } = shape.props;
+ const { x, y, rot } = shape;
+
+ const width = w;
+ const height = h ?? fontSize * 10;
+
+ if (rot === 0) {
+ return Box2Ops.create(x, y, x + width, y + height);
+ }
+
+ const corners = [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }];
+ const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot));
+ const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y }));
+ return Box2Ops.fromPoints(translatedCorners);
+}
+
/**
* Compute outline polygon points for a stroke using perfect-freehand
*
@@ -323,6 +347,22 @@ export function pointInText(p: Vec2, shape: TextShape): boolean {
return localP.x >= 0 && localP.x <= width && localP.y >= 0 && localP.y <= height;
}
+/**
+ * Check if a point is inside a markdown block shape
+ *
+ * @param p - Point in world coordinates
+ * @param shape - Markdown block shape
+ * @returns True if point is inside the markdown block bounds
+ */
+export function pointInMarkdown(p: Vec2, shape: MarkdownShape): boolean {
+ const { x, y, rot } = shape;
+ const { w, h, fontSize } = shape.props;
+ const localP = worldToLocal(p, x, y, rot);
+ const width = w;
+ const height = h ?? fontSize * 10;
+ return localP.x >= 0 && localP.x <= width && localP.y >= 0 && localP.y <= height;
+}
+
/**
* Check if a point is inside a polygon using ray casting algorithm
*
@@ -435,6 +475,12 @@ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5
}
break;
}
+ case "markdown": {
+ if (pointInMarkdown(worldPoint, shape)) {
+ return shape.id;
+ }
+ break;
+ }
case "stroke": {
if (hitTestStroke(worldPoint, shape)) {
return shape.id;
diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts
index f7fe20f..101a87a 100644
--- a/packages/core/src/model.ts
+++ b/packages/core/src/model.ts
@@ -67,6 +67,24 @@ export type ArrowProps = {
export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number };
+/**
+ * Markdown block properties
+ * - md: markdown source text
+ * - w: fixed width (required for layout)
+ * - h: auto-computed height from layout (optional override)
+ * - style: font and color settings
+ */
+export type MarkdownProps = {
+ md: string;
+ w: number;
+ h?: number;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ bg?: string;
+ border?: string;
+};
+
/**
* Point with optional pressure value (0-1)
* Format: [x, y, pressure?]
@@ -97,8 +115,7 @@ export type StrokeStyle = { color: string; opacity: number };
*/
export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig };
-export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke";
-
+export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke" | "markdown";
export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number };
export type RectShape = BaseShape & { type: "rect"; props: RectProps };
export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps };
@@ -106,8 +123,9 @@ export type LineShape = BaseShape & { type: "line"; props: LineProps };
export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps };
export type TextShape = BaseShape & { type: "text"; props: TextProps };
export type StrokeShape = BaseShape & { type: "stroke"; props: StrokeProps };
+export type MarkdownShape = BaseShape & { type: "markdown"; props: MarkdownProps };
-export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape;
+export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape | MarkdownShape;
export const ShapeRecord = {
/**
@@ -152,6 +170,13 @@ export const ShapeRecord = {
return { id: id ?? createId("shape"), type: "stroke", pageId, x, y, rot: 0, props: properties };
},
+ /**
+ * Create a markdown block shape
+ */
+ createMarkdown(pageId: string, x: number, y: number, properties: MarkdownProps, id?: string): MarkdownShape {
+ return { id: id ?? createId("shape"), type: "markdown", pageId, x, y, rot: 0, props: properties };
+ },
+
/**
* Clone a shape record
*/
@@ -180,6 +205,9 @@ export const ShapeRecord = {
},
};
}
+ if (shape.type === "markdown") {
+ return { ...shape, props: { ...shape.props } };
+ }
return { ...shape, props: { ...shape.props } } as ShapeRecord;
},
};
@@ -346,6 +374,19 @@ export function validateDoc(document: Document): ValidationResult {
errors.push(`Stroke shape '${shapeId}' has invalid opacity`);
}
+ break;
+ }
+ case "markdown": {
+ if (shape.props.fontSize <= 0) {
+ errors.push(`Markdown shape '${shapeId}' has invalid fontSize`);
+ }
+ if (shape.props.w <= 0) {
+ errors.push(`Markdown shape '${shapeId}' has invalid width`);
+ }
+ if (shape.props.h !== undefined && shape.props.h <= 0) {
+ errors.push(`Markdown shape '${shapeId}' has invalid height`);
+ }
+
break;
}
}
diff --git a/packages/core/src/reactivity.ts b/packages/core/src/reactivity.ts
index 4df02bc..2a85d1e 100644
--- a/packages/core/src/reactivity.ts
+++ b/packages/core/src/reactivity.ts
@@ -12,7 +12,7 @@ import {
import type { Document, PageRecord, ShapeRecord } from "./model";
import { Document as DocumentOps } from "./model";
-export type ToolId = "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen";
+export type ToolId = "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" | "markdown";
export type BindingPreview = { arrowId: string; targetShapeId: string; handle: "start" | "end" };
diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts
index 6ca0143..cd05fc9 100644
--- a/packages/core/src/tools/index.ts
+++ b/packages/core/src/tools/index.ts
@@ -1,4 +1,5 @@
export * from "./base";
+export * from "./markdown";
export * from "./pen";
export * from "./select";
export * from "./shape";
diff --git a/packages/core/src/tools/markdown.ts b/packages/core/src/tools/markdown.ts
new file mode 100644
index 0000000..2e5f179
--- /dev/null
+++ b/packages/core/src/tools/markdown.ts
@@ -0,0 +1,66 @@
+import type { Action } from "../actions";
+import { createId, ShapeRecord } from "../model";
+import type { EditorState, ToolId } from "../reactivity";
+import { getCurrentPage } from "../reactivity";
+import type { Tool } from "./base";
+
+/**
+ * Markdown tool creates markdown block shapes on click
+ *
+ * Features:
+ * - Click to create a markdown block at the pointer position
+ * - Block is created with default content and dimensions
+ * - Shape is immediately selected after creation
+ */
+export class MarkdownTool implements Tool {
+ readonly id: ToolId = "markdown";
+
+ onEnter(state: EditorState): EditorState {
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ return state;
+ }
+
+ onAction(state: EditorState, action: Action): EditorState {
+ switch (action.type) {
+ case "pointer-down": {
+ return this.handlePointerDown(state, action);
+ }
+ default: {
+ return state;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createMarkdown(currentPage.id, action.world.x, action.world.y, {
+ md: "# Markdown\n\nEdit me...",
+ w: 300,
+ h: 200,
+ fontSize: 16,
+ fontFamily: "sans-serif",
+ color: "#1f2933",
+ }, shapeId);
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+}
diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts
index 56ef637..7a53562 100644
--- a/packages/core/src/tools/select.ts
+++ b/packages/core/src/tools/select.ts
@@ -576,7 +576,9 @@ export class SelectTool implements Tool {
pointer: Vec2,
handle: HandleKind,
): ShapeRecord | null {
- if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") {
+ if (
+ initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown"
+ ) {
return null;
}
let minX = bounds.min.x;
@@ -633,6 +635,10 @@ export class SelectTool implements Tool {
return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } };
}
+ if (initial.type === "markdown") {
+ return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } };
+ }
+
// @ts-expect-error union mismatch
return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } };
}
@@ -756,7 +762,9 @@ export class SelectTool implements Tool {
if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) {
return null;
}
- if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") {
+ if (
+ initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown"
+ ) {
return null;
}
const currentAngle = Math.atan2(
diff --git a/packages/core/tests/markdown.test.ts b/packages/core/tests/markdown.test.ts
new file mode 100644
index 0000000..9bf75ec
--- /dev/null
+++ b/packages/core/tests/markdown.test.ts
@@ -0,0 +1,394 @@
+import { describe, expect, it } from "vitest";
+import { pointInMarkdown, shapeBounds } from "../src/geom";
+import type { MarkdownProps } from "../src/model";
+import { Document, PageRecord, ShapeRecord, validateDoc } from "../src/model";
+import { EditorState as EditorStateOps } from "../src/reactivity";
+
+const createProps = (overrides?: Partial