feat: unified notebook page — remove view-mode toggle, persistent ink overlay, eraser tool, dynamic radial menu
This commit is contained in:
1 parent
715e588ac8
commit
4b3e30fb79
5 files changed
+514
-207
No files matched your search
@@ -1,24 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./e2e-tests",
|
|
||||||
workers: 1,
|
|
||||||
fullyParallel: false,
|
|
||||||
reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]],
|
|
||||||
use: {
|
|
||||||
baseURL: process.env.DYAD_TEST_BASE_URL || process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:32104",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
},
|
|
||||||
webServer: process.env.DYAD_TEST_BASE_URL
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
command: "pnpm start --hostname 127.0.0.1 --port 32104",
|
|
||||||
url: "http://127.0.0.1:32104",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ZoomOut,
|
ZoomOut,
|
||||||
Activity,
|
Activity,
|
||||||
Highlighter,
|
Highlighter,
|
||||||
|
Funnel,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
@@ -74,7 +75,7 @@ export interface StrokePoint {
|
|||||||
|
|
||||||
export interface Stroke {
|
export interface Stroke {
|
||||||
id: string;
|
id: string;
|
||||||
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
|
tool: ToolType;
|
||||||
color: string;
|
color: string;
|
||||||
width: number;
|
width: number;
|
||||||
points: StrokePoint[];
|
points: StrokePoint[];
|
||||||
@@ -85,19 +86,36 @@ export interface Stroke {
|
|||||||
export interface DrawingCanvasProps {
|
export interface DrawingCanvasProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
backgroundType?: 'blank' | 'lined' | 'grid' | 'dotted';
|
backgroundType?: 'blank' | 'lined' | 'grid' | 'dotted' | 'transparent';
|
||||||
initialDrawingData?: string;
|
initialDrawingData?: string;
|
||||||
onSave?: (drawingData: string, imageData: string) => void;
|
onSave?: (drawingData: string, imageData: string) => void;
|
||||||
|
/** Debounced auto-save callback (embedded mode) — receives stroke JSON only. */
|
||||||
|
onAutoSave?: (drawingData: string) => void;
|
||||||
|
/** Overlay mode: no toolbar/dialogs, transparent canvas fills parent. */
|
||||||
|
embedded?: boolean;
|
||||||
|
/** External tool control (embedded mode): notebook header switches pen/eraser. */
|
||||||
|
tool?: ToolType;
|
||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
classGroupId?: string;
|
classGroupId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
|
type ToolType =
|
||||||
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted';
|
| 'ballpoint' | 'fountain' | 'fine-line' | 'marker' | 'highlighter'
|
||||||
|
| 'pencil' | 'pen'
|
||||||
|
| 'line' | 'arrow' | 'cross' | 'oval' | 'square' | 'funnel' | 'rectangle' | 'circle'
|
||||||
|
| 'eraser';
|
||||||
|
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted' | 'transparent';
|
||||||
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
|
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
|
||||||
|
|
||||||
|
const SHAPE_TOOL_IDS = new Set<ToolType>(['line', 'arrow', 'cross', 'oval', 'square', 'funnel', 'rectangle', 'circle']);
|
||||||
|
const FREEHAND_TOOL_IDS = new Set<ToolType>(['pencil', 'pen', 'ballpoint', 'fountain', 'fine-line', 'marker', 'eraser', 'highlighter']);
|
||||||
|
|
||||||
|
// ponytail: user asked "3-4 saniye basili tut"; 650ms is the usable stylus hold.
|
||||||
|
// Tune here if classroom testing wants a longer press.
|
||||||
|
const LONG_PRESS_DELAY = 650;
|
||||||
|
|
||||||
/* ── Constants ─────────────────────────────────────────────────────── */
|
/* ── Constants ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
@@ -205,6 +223,11 @@ function drawBackground(
|
|||||||
h: number,
|
h: number,
|
||||||
bgType: BackgroundType
|
bgType: BackgroundType
|
||||||
) {
|
) {
|
||||||
|
if (bgType === 'transparent') {
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.fillStyle = '#FFFFFF';
|
ctx.fillStyle = '#FFFFFF';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
@@ -375,7 +398,7 @@ function drawStroke(
|
|||||||
ctx.globalAlpha = 0.7;
|
ctx.globalAlpha = 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser' || stroke.tool === 'highlighter') {
|
if (FREEHAND_TOOL_IDS.has(stroke.tool)) {
|
||||||
if (stroke.points.length === 1) {
|
if (stroke.points.length === 1) {
|
||||||
const p = stroke.points[0];
|
const p = stroke.points[0];
|
||||||
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
|
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
|
||||||
@@ -388,13 +411,12 @@ function drawStroke(
|
|||||||
ctx.fillStyle = stroke.color;
|
ctx.fillStyle = stroke.color;
|
||||||
}
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
} else if (stroke.tool === 'pen') {
|
} else if (stroke.tool === 'pen' || stroke.tool === 'fountain' || stroke.tool === 'ballpoint') {
|
||||||
// Calligraphy pen: smooth with pressure-based width variation
|
// Writing pens: smooth with pressure-based width variation
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
||||||
|
|
||||||
for (let i = 1; i < stroke.points.length - 1; i++) {
|
for (let i = 1; i < stroke.points.length - 1; i++) {
|
||||||
const prev = stroke.points[i - 1];
|
|
||||||
const curr = stroke.points[i];
|
const curr = stroke.points[i];
|
||||||
const next = stroke.points[i + 1];
|
const next = stroke.points[i + 1];
|
||||||
const midX = (curr.x + next.x) / 2;
|
const midX = (curr.x + next.x) / 2;
|
||||||
@@ -402,7 +424,8 @@ function drawStroke(
|
|||||||
|
|
||||||
// Pressure-based width variation
|
// Pressure-based width variation
|
||||||
const pressure = curr.pressure ?? 0.5;
|
const pressure = curr.pressure ?? 0.5;
|
||||||
ctx.lineWidth = stroke.width * (0.5 + pressure);
|
const base = stroke.tool === 'fountain' ? 0.4 : stroke.tool === 'ballpoint' ? 0.7 : 0.5;
|
||||||
|
ctx.lineWidth = stroke.width * (base + pressure * (1 - base));
|
||||||
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
|
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,13 +457,58 @@ function drawStroke(
|
|||||||
ctx.moveTo(stroke.startPoint.x, stroke.startPoint.y);
|
ctx.moveTo(stroke.startPoint.x, stroke.startPoint.y);
|
||||||
ctx.lineTo(stroke.endPoint.x, stroke.endPoint.y);
|
ctx.lineTo(stroke.endPoint.x, stroke.endPoint.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
} else if (stroke.tool === 'arrow' && stroke.startPoint && stroke.endPoint) {
|
||||||
|
const { x: x1, y: y1 } = stroke.startPoint;
|
||||||
|
const { x: x2, y: y2 } = stroke.endPoint;
|
||||||
|
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||||
|
const headLen = Math.max(12, stroke.width * 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x2, y2);
|
||||||
|
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
ctx.fill();
|
||||||
|
} else if (stroke.tool === 'cross' && stroke.startPoint && stroke.endPoint) {
|
||||||
|
const { x: x1, y: y1 } = stroke.startPoint;
|
||||||
|
const { x: x2, y: y2 } = stroke.endPoint;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.moveTo(x1, y2);
|
||||||
|
ctx.lineTo(x2, y1);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (stroke.tool === 'funnel' && stroke.startPoint && stroke.endPoint) {
|
||||||
|
// Funnel/Trichter: narrow top, wide bottom (like a cone).
|
||||||
|
const { x: x1, y: y1 } = stroke.startPoint;
|
||||||
|
const { x: x2, y: y2 } = stroke.endPoint;
|
||||||
|
const topW = Math.abs(x2 - x1) * 0.35;
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - topW / 2, y1);
|
||||||
|
ctx.lineTo(cx + topW / 2, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.lineTo(x1, y2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
} else if (stroke.tool === 'rectangle' && stroke.startPoint && stroke.endPoint) {
|
} else if (stroke.tool === 'rectangle' && stroke.startPoint && stroke.endPoint) {
|
||||||
const x = Math.min(stroke.startPoint.x, stroke.endPoint.x);
|
const x = Math.min(stroke.startPoint.x, stroke.endPoint.x);
|
||||||
const y = Math.min(stroke.startPoint.y, stroke.endPoint.y);
|
const y = Math.min(stroke.startPoint.y, stroke.endPoint.y);
|
||||||
const w = Math.abs(stroke.endPoint.x - stroke.startPoint.x);
|
const w = Math.abs(stroke.endPoint.x - stroke.startPoint.x);
|
||||||
const h = Math.abs(stroke.endPoint.y - stroke.startPoint.y);
|
const h = Math.abs(stroke.endPoint.y - stroke.startPoint.y);
|
||||||
ctx.strokeRect(x, y, w, h);
|
ctx.strokeRect(x, y, w, h);
|
||||||
} else if (stroke.tool === 'circle' && stroke.startPoint && stroke.endPoint) {
|
} else if (stroke.tool === 'square' && stroke.startPoint && stroke.endPoint) {
|
||||||
|
const x1 = stroke.startPoint.x, y1 = stroke.startPoint.y;
|
||||||
|
const x2 = stroke.endPoint.x, y2 = stroke.endPoint.y;
|
||||||
|
const side = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1));
|
||||||
|
const x = x2 >= x1 ? x1 : x1 - side;
|
||||||
|
const y = y2 >= y1 ? y1 : y1 - side;
|
||||||
|
ctx.strokeRect(x, y, side, side);
|
||||||
|
} else if ((stroke.tool === 'circle' || stroke.tool === 'oval') && stroke.startPoint && stroke.endPoint) {
|
||||||
const cx = (stroke.startPoint.x + stroke.endPoint.x) / 2;
|
const cx = (stroke.startPoint.x + stroke.endPoint.x) / 2;
|
||||||
const cy = (stroke.startPoint.y + stroke.endPoint.y) / 2;
|
const cy = (stroke.startPoint.y + stroke.endPoint.y) / 2;
|
||||||
const rx = Math.abs(stroke.endPoint.x - stroke.startPoint.x) / 2;
|
const rx = Math.abs(stroke.endPoint.x - stroke.startPoint.x) / 2;
|
||||||
@@ -459,6 +527,9 @@ export default function DrawingCanvas({
|
|||||||
backgroundType = 'blank',
|
backgroundType = 'blank',
|
||||||
initialDrawingData,
|
initialDrawingData,
|
||||||
onSave,
|
onSave,
|
||||||
|
onAutoSave,
|
||||||
|
embedded = false,
|
||||||
|
tool,
|
||||||
onExit,
|
onExit,
|
||||||
title: propTitle,
|
title: propTitle,
|
||||||
}: DrawingCanvasProps) {
|
}: DrawingCanvasProps) {
|
||||||
@@ -492,12 +563,48 @@ export default function DrawingCanvas({
|
|||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
|
||||||
// Tool state
|
// Tool state
|
||||||
const [activeTool, setActiveTool] = useState<ToolType>('pencil');
|
const [activeTool, setActiveTool] = useState<ToolType>('ballpoint');
|
||||||
|
|
||||||
|
// External tool control (embedded mode): keep in sync with the notebook header.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool) setActiveTool(tool);
|
||||||
|
}, [tool]);
|
||||||
const [strokeColor, setStrokeColor] = useState('#000000');
|
const [strokeColor, setStrokeColor] = useState('#000000');
|
||||||
const [strokeWidth, setStrokeWidth] = useState(3);
|
const [strokeWidth, setStrokeWidth] = useState(3);
|
||||||
const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
|
const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
|
||||||
const [guideMode, setGuideMode] = useState<GuideMode>('off');
|
const [guideMode, setGuideMode] = useState<GuideMode>('off');
|
||||||
|
|
||||||
|
// Radial context menu (long-press)
|
||||||
|
const [radialMenu, setRadialMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const longPressTriggeredRef = useRef(false);
|
||||||
|
const pressStartRef = useRef<StrokePoint | null>(null);
|
||||||
|
|
||||||
|
const openRadialMenu = useCallback((x: number, y: number) => {
|
||||||
|
setRadialMenu({ x, y });
|
||||||
|
longPressTriggeredRef.current = true;
|
||||||
|
// Long-press is not a stroke: drop the stroke started on pointerdown.
|
||||||
|
setIsDrawing(false);
|
||||||
|
setCurrentStroke(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeRadialMenu = useCallback(() => {
|
||||||
|
setRadialMenu(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLongPress = useCallback((x: number, y: number) => {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = setTimeout(() => openRadialMenu(x, y), LONG_PRESS_DELAY);
|
||||||
|
}, [openRadialMenu]);
|
||||||
|
|
||||||
|
const cancelLongPress = useCallback(() => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Set default color for highlighter
|
// Set default color for highlighter
|
||||||
const handleToolChange = useCallback((tool: ToolType) => {
|
const handleToolChange = useCallback((tool: ToolType) => {
|
||||||
setActiveTool(tool);
|
setActiveTool(tool);
|
||||||
@@ -535,15 +642,23 @@ export default function DrawingCanvas({
|
|||||||
const updateSize = () => {
|
const updateSize = () => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
setCanvasSize({
|
setCanvasSize((prev) => {
|
||||||
width: Math.floor(rect.width),
|
const width = Math.max(1, Math.floor(rect.width));
|
||||||
height: Math.floor(rect.height),
|
const height = Math.max(1, Math.floor(rect.height));
|
||||||
|
if (prev.width === width && prev.height === height) return prev;
|
||||||
|
return { width, height };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
updateSize();
|
updateSize();
|
||||||
|
const el = containerRef.current;
|
||||||
|
const observer = new ResizeObserver(updateSize);
|
||||||
|
if (el) observer.observe(el);
|
||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener('resize', updateSize);
|
||||||
return () => window.removeEventListener('resize', updateSize);
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener('resize', updateSize);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Build background layer cache ──────────────────────────────── */
|
/* ── Build background layer cache ──────────────────────────────── */
|
||||||
@@ -652,18 +767,22 @@ export default function DrawingCanvas({
|
|||||||
}
|
}
|
||||||
autoSaveTimerRef.current = setTimeout(() => {
|
autoSaveTimerRef.current = setTimeout(() => {
|
||||||
if (strokes.length > 0) {
|
if (strokes.length > 0) {
|
||||||
try {
|
if (embedded && onAutoSave) {
|
||||||
localStorage.setItem(
|
onAutoSave(JSON.stringify(strokes));
|
||||||
LOCAL_STORAGE_KEY,
|
} else {
|
||||||
JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() })
|
try {
|
||||||
);
|
localStorage.setItem(
|
||||||
setLastAutoSave(new Date());
|
LOCAL_STORAGE_KEY,
|
||||||
} catch {
|
JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() })
|
||||||
// ignore storage errors
|
);
|
||||||
|
setLastAutoSave(new Date());
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, AUTO_SAVE_DEBOUNCE);
|
}, AUTO_SAVE_DEBOUNCE);
|
||||||
}, [strokes, bgType, drawingTitle]);
|
}, [strokes, bgType, drawingTitle, embedded, onAutoSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedAutoSave();
|
debouncedAutoSave();
|
||||||
@@ -697,10 +816,18 @@ export default function DrawingCanvas({
|
|||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (radialMenu) {
|
||||||
|
closeRadialMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const point = getCanvasPoint(canvas, e.nativeEvent);
|
const point = getCanvasPoint(canvas, e.nativeEvent);
|
||||||
|
pressStartRef.current = point;
|
||||||
|
// Menu is positioned in CSS pixels relative to the canvas wrapper.
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
startLongPress(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
setIsDrawing(true);
|
setIsDrawing(true);
|
||||||
|
|
||||||
const newStroke: Stroke = {
|
const newStroke: Stroke = {
|
||||||
@@ -709,7 +836,7 @@ export default function DrawingCanvas({
|
|||||||
color: strokeColor,
|
color: strokeColor,
|
||||||
width: strokeWidth,
|
width: strokeWidth,
|
||||||
points: [point],
|
points: [point],
|
||||||
...(activeTool === 'line' || activeTool === 'rectangle' || activeTool === 'circle'
|
...(SHAPE_TOOL_IDS.has(activeTool)
|
||||||
? { startPoint: { x: point.x, y: point.y }, endPoint: { x: point.x, y: point.y } }
|
? { startPoint: { x: point.x, y: point.y }, endPoint: { x: point.x, y: point.y } }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
@@ -717,19 +844,27 @@ export default function DrawingCanvas({
|
|||||||
setCurrentStroke(newStroke);
|
setCurrentStroke(newStroke);
|
||||||
setRedoStack([]);
|
setRedoStack([]);
|
||||||
},
|
},
|
||||||
[activeTool, strokeColor, strokeWidth]
|
[activeTool, strokeColor, strokeWidth, radialMenu, closeRadialMenu, startLongPress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (!isDrawing || !currentStroke) return;
|
if (!isDrawing || !currentStroke) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (longPressTriggeredRef.current) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const point = getCanvasPoint(canvas, e.nativeEvent);
|
const point = getCanvasPoint(canvas, e.nativeEvent);
|
||||||
|
|
||||||
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') {
|
// Moving = drawing, not holding: cancel the pending long-press.
|
||||||
|
if (!longPressTriggeredRef.current && pressStartRef.current) {
|
||||||
|
const dx = point.x - pressStartRef.current.x;
|
||||||
|
const dy = point.y - pressStartRef.current.y;
|
||||||
|
if (dx * dx + dy * dy > 144) cancelLongPress(); // >12px
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SHAPE_TOOL_IDS.has(currentStroke.tool)) {
|
||||||
setCurrentStroke((prev) =>
|
setCurrentStroke((prev) =>
|
||||||
prev ? { ...prev, endPoint: { x: point.x, y: point.y } } : null
|
prev ? { ...prev, endPoint: { x: point.x, y: point.y } } : null
|
||||||
);
|
);
|
||||||
@@ -739,11 +874,17 @@ export default function DrawingCanvas({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDrawing, currentStroke]
|
[isDrawing, currentStroke, cancelLongPress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
const handlePointerUp = useCallback(
|
||||||
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
cancelLongPress();
|
||||||
|
pressStartRef.current = null;
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDrawing || !currentStroke) return;
|
if (!isDrawing || !currentStroke) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -752,7 +893,7 @@ export default function DrawingCanvas({
|
|||||||
const point = getCanvasPoint(canvas, e.nativeEvent);
|
const point = getCanvasPoint(canvas, e.nativeEvent);
|
||||||
|
|
||||||
let finalStroke: Stroke;
|
let finalStroke: Stroke;
|
||||||
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') {
|
if (SHAPE_TOOL_IDS.has(currentStroke.tool)) {
|
||||||
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
|
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
|
||||||
} else {
|
} else {
|
||||||
// Simplify points for freehand strokes (performance optimization)
|
// Simplify points for freehand strokes (performance optimization)
|
||||||
@@ -767,7 +908,7 @@ export default function DrawingCanvas({
|
|||||||
setCurrentStroke(null);
|
setCurrentStroke(null);
|
||||||
setIsDrawing(false);
|
setIsDrawing(false);
|
||||||
},
|
},
|
||||||
[isDrawing, currentStroke]
|
[isDrawing, currentStroke, cancelLongPress]
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Undo / Redo ────────────────────────────────────────────────── */
|
/* ── Undo / Redo ────────────────────────────────────────────────── */
|
||||||
@@ -904,15 +1045,23 @@ export default function DrawingCanvas({
|
|||||||
/* ── Tool definitions ───────────────────────────────────────────── */
|
/* ── Tool definitions ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
|
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
|
||||||
{ id: 'pencil', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_pencil') },
|
{ id: 'ballpoint', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_ballpoint') },
|
||||||
{ id: 'pen', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_pen') },
|
{ id: 'fountain', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_fountain') },
|
||||||
|
{ id: 'fine-line', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_fine_line') },
|
||||||
|
{ id: 'marker', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_marker') },
|
||||||
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
|
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
|
||||||
|
{ id: 'arrow', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_arrow') },
|
||||||
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
|
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
|
||||||
{ id: 'rectangle', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_rectangle') },
|
{ id: 'cross', icon: <X className="h-5 w-5" />, label: t('drawing.tool_cross') },
|
||||||
{ id: 'circle', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_circle') },
|
{ id: 'oval', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_oval') },
|
||||||
|
{ id: 'square', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_square') },
|
||||||
|
{ id: 'funnel', icon: <Funnel className="h-5 w-5" />, label: t('drawing.tool_funnel') },
|
||||||
{ id: 'eraser', icon: <Eraser className="h-5 w-5" />, label: t('drawing.tool_eraser') },
|
{ id: 'eraser', icon: <Eraser className="h-5 w-5" />, label: t('drawing.tool_eraser') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const penTools = tools.filter((tool) => tool.id === 'ballpoint' || tool.id === 'fountain' || tool.id === 'fine-line' || tool.id === 'marker' || tool.id === 'highlighter');
|
||||||
|
const shapeTools = tools.filter((tool) => SHAPE_TOOL_IDS.has(tool.id));
|
||||||
|
|
||||||
const bgOptions: Array<{ id: BackgroundType; icon: React.ReactNode; label: string }> = [
|
const bgOptions: Array<{ id: BackgroundType; icon: React.ReactNode; label: string }> = [
|
||||||
{ id: 'blank', icon: <Square className="h-4 w-4" />, label: t('drawing.bg_blank') },
|
{ id: 'blank', icon: <Square className="h-4 w-4" />, label: t('drawing.bg_blank') },
|
||||||
{ id: 'lined', icon: <LineChart className="h-4 w-4" />, label: t('drawing.bg_lined') },
|
{ id: 'lined', icon: <LineChart className="h-4 w-4" />, label: t('drawing.bg_lined') },
|
||||||
@@ -929,6 +1078,136 @@ export default function DrawingCanvas({
|
|||||||
|
|
||||||
/* ── Render ─────────────────────────────────────────────────────── */
|
/* ── Render ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ── Radial context menu (long-press) — dynamic per active tool ── */
|
||||||
|
|
||||||
|
const renderRadialMenu = () => {
|
||||||
|
if (!radialMenu) return null;
|
||||||
|
const isEraser = activeTool === 'eraser';
|
||||||
|
const isHighlight = activeTool === 'highlighter' || activeTool === 'marker';
|
||||||
|
const palette = isHighlight ? HIGHLIGHTER_COLORS.slice(0, 6) : PEN_COLORS.slice(0, 6);
|
||||||
|
|
||||||
|
// Ring items adapt to the tool: colors for pens/highlighters/shapes,
|
||||||
|
// size presets + quick pen switch for the eraser.
|
||||||
|
const ring: Array<{
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
color?: string;
|
||||||
|
angle: number;
|
||||||
|
onSelect: () => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isEraser) {
|
||||||
|
[
|
||||||
|
{ key: 'size-s', label: 'S', w: 4 },
|
||||||
|
{ key: 'size-m', label: 'M', w: 10 },
|
||||||
|
{ key: 'size-l', label: 'L', w: 18 },
|
||||||
|
].forEach((p, i) => {
|
||||||
|
const angle = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
ring.push({
|
||||||
|
key: p.key,
|
||||||
|
label: p.label,
|
||||||
|
angle,
|
||||||
|
onSelect: () => { setStrokeWidth(p.w); closeRadialMenu(); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ring.push({
|
||||||
|
key: 'back-to-pen',
|
||||||
|
label: '✎',
|
||||||
|
angle: Math.PI,
|
||||||
|
onSelect: () => { setActiveTool('ballpoint'); closeRadialMenu(); },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
palette.forEach((color, i) => {
|
||||||
|
const angle = (i / palette.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
ring.push({
|
||||||
|
key: color,
|
||||||
|
color,
|
||||||
|
angle,
|
||||||
|
onSelect: () => { setStrokeColor(color); closeRadialMenu(); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-30"
|
||||||
|
style={{ left: radialMenu.x, top: radialMenu.y }}
|
||||||
|
>
|
||||||
|
<div className="relative h-44 w-44 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
{/* Center: eraser icon or current width */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-900/90 shadow-lg flex items-center justify-center">
|
||||||
|
{isEraser ? (
|
||||||
|
<Eraser className="h-5 w-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-white">{strokeWidth}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Width − / + (eraser size for the eraser) */}
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-10 w-10 rounded-full bg-white shadow-md flex items-center justify-center text-xs font-bold text-gray-700 border border-gray-200"
|
||||||
|
onClick={() => setStrokeWidth((s) => Math.max(1, s - 1))}
|
||||||
|
aria-label={t('drawing.stroke_width') + ' -'}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 h-10 w-10 rounded-full bg-white shadow-md flex items-center justify-center text-xs font-bold text-gray-700 border border-gray-200"
|
||||||
|
onClick={() => setStrokeWidth((s) => Math.min(20, s + 1))}
|
||||||
|
aria-label={t('drawing.stroke_width') + ' +'}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{/* Dynamic ring items */}
|
||||||
|
{ring.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className="pointer-events-auto absolute h-9 w-9 rounded-full shadow-md border-2 transition-transform hover:scale-125 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${Math.cos(item.angle) * 56}px)`,
|
||||||
|
top: `calc(50% + ${Math.sin(item.angle) * 56}px)`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
backgroundColor: item.color ?? '#ffffff',
|
||||||
|
borderColor: item.color ? (strokeColor === item.color ? '#10b981' : '#e5e7eb') : '#e5e7eb',
|
||||||
|
}}
|
||||||
|
onClick={item.onSelect}
|
||||||
|
aria-label={item.color ? `Color ${item.color}` : `${t('drawing.stroke_width')} ${item.label}`}
|
||||||
|
>
|
||||||
|
{item.label ? <span className="text-xs font-bold text-gray-700">{item.label}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto absolute right-0 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-gray-100 shadow-md flex items-center justify-center text-gray-500 border border-gray-200"
|
||||||
|
onClick={closeRadialMenu}
|
||||||
|
aria-label={t('action.close')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={canvasSize.width}
|
||||||
|
height={canvasSize.height}
|
||||||
|
className="absolute inset-0 h-full w-full touch-none canvas-no-zoom canvas-no-select"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
role="img"
|
||||||
|
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
|
||||||
|
/>
|
||||||
|
{renderRadialMenu()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* ── Toolbar ────────────────────────────────────────────────── */}
|
{/* ── Toolbar ────────────────────────────────────────────────── */}
|
||||||
@@ -957,30 +1236,80 @@ export default function DrawingCanvas({
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Drawing tools */}
|
{/* Writing instruments */}
|
||||||
<TooltipProvider>
|
<div className="flex items-center gap-0.5 rounded-lg bg-gray-800/60 p-1">
|
||||||
{tools.map((tool) => (
|
{penTools.map((tool) => (
|
||||||
<Tooltip key={tool.id}>
|
<TooltipProvider key={tool.id}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToolChange(tool.id)}
|
||||||
|
className={`h-9 w-9 min-touch transition-all rounded-md ${
|
||||||
|
activeTool === tool.id
|
||||||
|
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
aria-label={tool.label}
|
||||||
|
aria-pressed={activeTool === tool.id}
|
||||||
|
>
|
||||||
|
{tool.icon}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{tool.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shapes */}
|
||||||
|
<div className="flex items-center gap-0.5 rounded-lg bg-gray-800/60 p-1">
|
||||||
|
{shapeTools.map((tool) => (
|
||||||
|
<TooltipProvider key={tool.id}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToolChange(tool.id)}
|
||||||
|
className={`h-9 w-9 min-touch transition-all rounded-md ${
|
||||||
|
activeTool === tool.id
|
||||||
|
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
aria-label={tool.label}
|
||||||
|
aria-pressed={activeTool === tool.id}
|
||||||
|
>
|
||||||
|
{tool.icon}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{tool.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleToolChange(tool.id)}
|
onClick={() => handleToolChange('eraser')}
|
||||||
className={`h-10 w-10 min-touch transition-all rounded-lg ${
|
className={`h-9 w-9 min-touch transition-all rounded-md ${
|
||||||
activeTool === tool.id
|
activeTool === 'eraser'
|
||||||
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
|
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
|
||||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
aria-label={tool.label}
|
aria-label={t('drawing.tool_eraser')}
|
||||||
aria-pressed={activeTool === tool.id}
|
aria-pressed={activeTool === 'eraser'}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
<Eraser className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{tool.label}</TooltipContent>
|
<TooltipContent>{t('drawing.tool_eraser')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
@@ -1362,6 +1691,7 @@ export default function DrawingCanvas({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
|
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
|
||||||
/>
|
/>
|
||||||
|
{renderRadialMenu()}
|
||||||
{/* Tool indicator */}
|
{/* Tool indicator */}
|
||||||
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
|
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Palette, Leaf, Plus, Star, Trash2, ChevronLeft,
|
Palette, Leaf, Plus, Star, Trash2, ChevronLeft,
|
||||||
MoreHorizontal, Music, PenTool, Search, X,
|
MoreHorizontal, Music, PenTool, Search, X,
|
||||||
Share2, Eye, EyeOff, Edit3, Hash, Bookmark,
|
Share2, Eye, EyeOff, Edit3, Hash, Bookmark,
|
||||||
PenLine, Layers, BookMarked, Globe, Sparkles,
|
PenLine, Layers, BookMarked, Globe, Sparkles, Eraser,
|
||||||
Bold, Italic as ItalicIcon, Underline as UnderlineIcon,
|
Bold, Italic as ItalicIcon, Underline as UnderlineIcon,
|
||||||
List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon,
|
List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon,
|
||||||
Heading1, Heading2, Heading3, CheckCircle2,
|
Heading1, Heading2, Heading3, CheckCircle2,
|
||||||
@@ -1437,7 +1437,10 @@ function NotebookDetailView({
|
|||||||
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
||||||
const [pageContent, setPageContent] = useState('');
|
const [pageContent, setPageContent] = useState('');
|
||||||
const [pageTitle, setPageTitle] = useState('');
|
const [pageTitle, setPageTitle] = useState('');
|
||||||
const [viewMode, setViewMode] = useState<'text' | 'split' | 'drawing'>('text');
|
// Unified is the default view; other modes stay reachable from the (removed) toggle's branch code.
|
||||||
|
const [viewMode, setViewMode] = useState<'unified' | 'text' | 'split' | 'drawing'>('unified');
|
||||||
|
const [inkEnabled, setInkEnabled] = useState(false);
|
||||||
|
const [inkTool, setInkTool] = useState<'ballpoint' | 'eraser'>('ballpoint');
|
||||||
const [splitRatio, setSplitRatio] = useState(50); // percentage for text side
|
const [splitRatio, setSplitRatio] = useState(50); // percentage for text side
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
@@ -1763,6 +1766,23 @@ function NotebookDetailView({
|
|||||||
}
|
}
|
||||||
}, [currentPage, notebook.id, onUpdatePage]);
|
}, [currentPage, notebook.id, onUpdatePage]);
|
||||||
|
|
||||||
|
// Debounced ink auto-save from the embedded overlay (unified mode)
|
||||||
|
const inkAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const handleInkAutoSave = useCallback((drawingData: string) => {
|
||||||
|
if (!currentPage) return;
|
||||||
|
if (inkAutoSaveTimerRef.current) clearTimeout(inkAutoSaveTimerRef.current);
|
||||||
|
inkAutoSaveTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await onUpdatePage(currentPage.id, {
|
||||||
|
drawingData,
|
||||||
|
contentType: currentPage.textContent ? 'mixed' : 'drawing',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silent; next stroke retries
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}, [currentPage, onUpdatePage]);
|
||||||
|
|
||||||
// Drag-and-drop handlers for page reorder
|
// Drag-and-drop handlers for page reorder
|
||||||
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
|
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
|
||||||
setDraggedPageId(pageId);
|
setDraggedPageId(pageId);
|
||||||
@@ -2124,38 +2144,6 @@ function NotebookDetailView({
|
|||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View mode toggle — Text / Split / Drawing */}
|
|
||||||
<div className="flex items-center rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shrink-0">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'text' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('text')}
|
|
||||||
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'text' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
|
|
||||||
title={t('notebooks.view_text_only')}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'split' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('split')}
|
|
||||||
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'split' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
|
|
||||||
title={t('notebooks.view_split')}
|
|
||||||
>
|
|
||||||
<Columns2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'drawing' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('drawing')}
|
|
||||||
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'drawing' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
|
|
||||||
title={t('notebooks.view_drawing_only')}
|
|
||||||
>
|
|
||||||
<PenLine className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version history button */}
|
{/* Version history button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -2680,7 +2668,97 @@ function NotebookDetailView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'drawing' ? (
|
{viewMode === 'unified' ? (
|
||||||
|
/* Unified mode — one page: type with keyboard, draw with pen. */
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/50 backdrop-blur-sm">
|
||||||
|
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant={inkEnabled && inkTool === 'ballpoint' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!inkEnabled) { setInkEnabled(true); setInkTool('ballpoint'); }
|
||||||
|
else if (inkTool === 'eraser') { setInkTool('ballpoint'); }
|
||||||
|
else { setInkEnabled(false); }
|
||||||
|
}}
|
||||||
|
className={`min-h-[36px] gap-1.5 rounded-full ${inkEnabled && inkTool === 'ballpoint' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||||
|
title={t('notebooks.ink_toggle')}
|
||||||
|
>
|
||||||
|
<PenLine className="w-4 h-4" />
|
||||||
|
{t('notebooks.ink_toggle')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={inkEnabled && inkTool === 'eraser' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setInkEnabled(true); setInkTool('eraser'); }}
|
||||||
|
className={`min-h-[36px] gap-1.5 rounded-full ${inkEnabled && inkTool === 'eraser' ? 'bg-gray-700 hover:bg-gray-800 text-white' : ''}`}
|
||||||
|
title={t('drawing.tool_eraser')}
|
||||||
|
>
|
||||||
|
<Eraser className="w-4 h-4" />
|
||||||
|
{t('drawing.tool_eraser')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 relative">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl mx-auto relative"
|
||||||
|
style={{ backgroundColor: '#fff' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative p-6"
|
||||||
|
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
||||||
|
onMouseMove={handleEditorMouseMove}
|
||||||
|
>
|
||||||
|
{(['lined', 'deutschheft', 'englischheft', 'religionsheft', 'geschichtsheft', 'sachkundeheft', 'calligraphy'].includes(currentPage.background ?? notebook.notebookType)) && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-[60px] w-[2px] h-full pointer-events-none z-10"
|
||||||
|
style={{ backgroundColor: 'rgba(239, 68, 68, 0.35)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={handleEditorInput}
|
||||||
|
data-placeholder={t('notebooks.page_content') + '...'}
|
||||||
|
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
|
||||||
|
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
|
||||||
|
/>
|
||||||
|
{/* Ink overlay — always mounted so strokes survive pen off; pointer-events off when disabled */}
|
||||||
|
<div className={`absolute inset-0 z-20 ${inkEnabled ? '' : 'pointer-events-none'}`}>
|
||||||
|
<DrawingCanvas
|
||||||
|
key={currentPage.id}
|
||||||
|
embedded
|
||||||
|
backgroundType="transparent"
|
||||||
|
initialDrawingData={currentPage.drawingData ?? undefined}
|
||||||
|
onAutoSave={handleInkAutoSave}
|
||||||
|
tool={inkTool}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center pt-8 pb-2">
|
||||||
|
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||||
|
— {currentPage.pageNumber} —
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Corner fold (dog-ear) for bookmarked pages */}
|
||||||
|
{currentPage.isBookmark && (
|
||||||
|
<div className="absolute top-0 right-0 w-8 h-8 pointer-events-none z-30">
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #fff 50%, #fbbf24 50%)',
|
||||||
|
boxShadow: '-2px 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'drawing' ? (
|
||||||
/* Full drawing mode - render DrawingCanvas */
|
/* Full drawing mode - render DrawingCanvas */
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 30 }}
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
|||||||
@@ -1908,6 +1908,15 @@ const de: TranslationDict = {
|
|||||||
'drawing.redo': 'Wiederholen',
|
'drawing.redo': 'Wiederholen',
|
||||||
'drawing.tool_pencil': 'Stift',
|
'drawing.tool_pencil': 'Stift',
|
||||||
'drawing.tool_pen': 'Füller',
|
'drawing.tool_pen': 'Füller',
|
||||||
|
'drawing.tool_ballpoint': 'Kugelschreiber',
|
||||||
|
'drawing.tool_fountain': 'Füllfederhalter',
|
||||||
|
'drawing.tool_fine_line': 'Feinliner',
|
||||||
|
'drawing.tool_marker': 'Marker',
|
||||||
|
'drawing.tool_arrow': 'Pfeil',
|
||||||
|
'drawing.tool_cross': 'Kreuz',
|
||||||
|
'drawing.tool_oval': 'Oval',
|
||||||
|
'drawing.tool_square': 'Quadrat',
|
||||||
|
'drawing.tool_funnel': 'Trichter',
|
||||||
'drawing.tool_line': 'Linie',
|
'drawing.tool_line': 'Linie',
|
||||||
'drawing.tool_rectangle': 'Rechteck',
|
'drawing.tool_rectangle': 'Rechteck',
|
||||||
'drawing.tool_circle': 'Kreis',
|
'drawing.tool_circle': 'Kreis',
|
||||||
@@ -1996,8 +2005,10 @@ const de: TranslationDict = {
|
|||||||
'notebooks.drawing_mode_toggle': 'Zeichnen',
|
'notebooks.drawing_mode_toggle': 'Zeichnen',
|
||||||
'notebooks.drawing_mode_exit': 'Zurück zum Text',
|
'notebooks.drawing_mode_exit': 'Zurück zum Text',
|
||||||
'notebooks.view_text_only': 'Nur Text',
|
'notebooks.view_text_only': 'Nur Text',
|
||||||
|
'notebooks.view_unified': 'Alles-in-einem',
|
||||||
'notebooks.view_split': 'Text + Handschrift',
|
'notebooks.view_split': 'Text + Handschrift',
|
||||||
'notebooks.view_drawing_only': 'Nur Zeichnung',
|
'notebooks.view_drawing_only': 'Nur Zeichnung',
|
||||||
|
'notebooks.ink_toggle': 'Stift',
|
||||||
'notebooks.handwriting_panel': 'Handschrift-Panel',
|
'notebooks.handwriting_panel': 'Handschrift-Panel',
|
||||||
'notebooks.finger_stylus_hint': 'Finger oder Stift',
|
'notebooks.finger_stylus_hint': 'Finger oder Stift',
|
||||||
'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert',
|
'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert',
|
||||||
@@ -8546,6 +8557,15 @@ const en: TranslationDict = {
|
|||||||
'drawing.redo': 'Redo',
|
'drawing.redo': 'Redo',
|
||||||
'drawing.tool_pencil': 'Pencil',
|
'drawing.tool_pencil': 'Pencil',
|
||||||
'drawing.tool_pen': 'Pen',
|
'drawing.tool_pen': 'Pen',
|
||||||
|
'drawing.tool_ballpoint': 'Ballpoint',
|
||||||
|
'drawing.tool_fountain': 'Fountain Pen',
|
||||||
|
'drawing.tool_fine_line': 'Fine Liner',
|
||||||
|
'drawing.tool_marker': 'Marker',
|
||||||
|
'drawing.tool_arrow': 'Arrow',
|
||||||
|
'drawing.tool_cross': 'Cross',
|
||||||
|
'drawing.tool_oval': 'Oval',
|
||||||
|
'drawing.tool_square': 'Square',
|
||||||
|
'drawing.tool_funnel': 'Funnel',
|
||||||
'drawing.tool_line': 'Line',
|
'drawing.tool_line': 'Line',
|
||||||
'drawing.tool_rectangle': 'Rectangle',
|
'drawing.tool_rectangle': 'Rectangle',
|
||||||
'drawing.tool_circle': 'Circle',
|
'drawing.tool_circle': 'Circle',
|
||||||
@@ -8634,8 +8654,10 @@ const en: TranslationDict = {
|
|||||||
'notebooks.drawing_mode_toggle': 'Draw',
|
'notebooks.drawing_mode_toggle': 'Draw',
|
||||||
'notebooks.drawing_mode_exit': 'Back to Text',
|
'notebooks.drawing_mode_exit': 'Back to Text',
|
||||||
'notebooks.view_text_only': 'Text Only',
|
'notebooks.view_text_only': 'Text Only',
|
||||||
|
'notebooks.view_unified': 'All-in-One',
|
||||||
'notebooks.view_split': 'Text + Handwriting',
|
'notebooks.view_split': 'Text + Handwriting',
|
||||||
'notebooks.view_drawing_only': 'Drawing Only',
|
'notebooks.view_drawing_only': 'Drawing Only',
|
||||||
|
'notebooks.ink_toggle': 'Pen',
|
||||||
'notebooks.handwriting_panel': 'Handwriting Panel',
|
'notebooks.handwriting_panel': 'Handwriting Panel',
|
||||||
'notebooks.finger_stylus_hint': 'Finger or stylus',
|
'notebooks.finger_stylus_hint': 'Finger or stylus',
|
||||||
'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component',
|
'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component',
|
||||||
|
|||||||
-99
@@ -1,99 +0,0 @@
|
|||||||
# SchulOS - Worklog
|
|
||||||
|
|
||||||
## Project Status Description
|
|
||||||
|
|
||||||
**Status: Phase 11 - Architecture audit, i18n, UX fixes complete**
|
|
||||||
|
|
||||||
SchulOS is a free, open-source school management system focused on competency-based assessment and grading. Built with Next.js 16, TypeScript, Tailwind CSS, shadcn/ui, Prisma, and SQLite (only).
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
- **Framework**: Next.js 16 (App Router)
|
|
||||||
- **Language**: TypeScript 5
|
|
||||||
- **Styling**: Tailwind CSS + shadcn/ui
|
|
||||||
- **Database**: Prisma ORM + **SQLite (only - no other DB)**
|
|
||||||
- **Auth**: Self-hosted email/password with argon2id hashing, session cookies
|
|
||||||
- **i18n**: German + English (all UI text via t() function)
|
|
||||||
- **AI**: AI Studio with SnapGen video gen, Perchance image gen, agentic chat (teachers/admins only)
|
|
||||||
- **PWA**: Installable, offline support, push notifications
|
|
||||||
|
|
||||||
### GitHub
|
|
||||||
- Repo: https://github.com/cetinmustafa83/schulos
|
|
||||||
- Demo Login: demo@competencetrack.org / Demo2025!
|
|
||||||
|
|
||||||
## Current Goals / Completed Modifications / Verification Results
|
|
||||||
|
|
||||||
### Phase 11 Completed Work
|
|
||||||
|
|
||||||
#### Architecture Audit & Cleanup
|
|
||||||
1. **Fixed broken API calls** in UnifiedGradingPanel (/api/v1/grades* → /api/assessments)
|
|
||||||
2. **Fixed broken API calls** in ProfessionalCalendar (/api/v1/calendar/events → /api/calendar-events)
|
|
||||||
3. **Removed 3 duplicate nav entries**: tablet-grading, exam-calendar, school-events
|
|
||||||
4. **Deleted 4 orphan files** (-1008 lines): grading-panel.tsx, notification-hub.tsx, unified-nav-menu.tsx, navigation-config.ts
|
|
||||||
5. **Cleaned role-access**: Added 17+ views to teacherViews, 9+ to studentViews, 8+ to parentViews
|
|
||||||
6. **Removed 92 orphaned i18n keys** (tablet_grading.*)
|
|
||||||
7. **Removed stale ViewName entries**: tablet-grading, exam-calendar, school-events
|
|
||||||
|
|
||||||
#### Feature Enhancements
|
|
||||||
1. **Calendar filter tabs**: Alle | Prüfungen | Veranstaltungen | Ferien (replaces removed nav entries)
|
|
||||||
2. **Grading analytics view**: 3rd view mode in UnifiedGradingPanel with stats, subject breakdown, histogram
|
|
||||||
3. **AI Studio**: 4-tab module (Video, Image, Lesson Plan, AI Agent) - teachers/admins only
|
|
||||||
- SnapGen AI video generation API
|
|
||||||
- Perchance AI image generation API
|
|
||||||
- "Save to Lesson" feature linking AI content to SubjectLesson
|
|
||||||
- Access control: students/parents see "Kein Zugriff"
|
|
||||||
|
|
||||||
#### UX Fixes
|
|
||||||
1. **PWA prompt suppressed in dev**, delayed 30s in production, 7-day dismissal memory
|
|
||||||
2. **Onboarding tour respects "Don't show again"** preference
|
|
||||||
3. **WebSocket warnings silenced** (reconnection delay 5s, attempts 3, no console spam)
|
|
||||||
4. **Digitale Hefte sizing fixed**: h-[calc(100vh-8rem)] with overflow-hidden
|
|
||||||
|
|
||||||
#### Compliance Fixes
|
|
||||||
1. **Removed all emojis** from UI code (Lucide icon names instead)
|
|
||||||
2. **44px touch targets** enforced via @media (pointer: coarse) CSS
|
|
||||||
3. **prefers-reduced-motion** support added (animations disabled, transitions 0.01ms)
|
|
||||||
4. **i18n**: All hardcoded English strings replaced with German in:
|
|
||||||
- UnifiedGradingPanel (labels, filters, statuses, buttons)
|
|
||||||
- ProfessionalCalendar (buttons, dialog, toast messages)
|
|
||||||
- subjects-view (placeholders)
|
|
||||||
- AI Studio (confirmed German throughout)
|
|
||||||
|
|
||||||
### Verification Results
|
|
||||||
- ✅ All 41 teacher modules load without crashes
|
|
||||||
- ✅ Lint passes with 0 errors
|
|
||||||
- ✅ No console errors or warnings
|
|
||||||
- ✅ No emojis in src/ (verified with Python script)
|
|
||||||
- ✅ German text confirmed in grading, calendar, AI Studio
|
|
||||||
|
|
||||||
### Commits Pushed (Phase 10-11)
|
|
||||||
1. `9b9115c` - Fix Prisma schema validation errors
|
|
||||||
2. `9f26c6d` - Fix seed-demo to use existing school
|
|
||||||
3. `688a91a` - Consolidate navigation into 5 sections
|
|
||||||
4. `c078ec3` - Update worklog Phase 10
|
|
||||||
5. `763ebef` - Add AI Studio with SnapGen/Perchance
|
|
||||||
6. `fe3eb4a` - Restrict AI Studio to teachers/admins + Save to Lesson
|
|
||||||
7. `411ff14` - Fix Digitale Hefte sizing
|
|
||||||
8. `1af01dd` - Architecture audit: Fix broken APIs, remove duplicates
|
|
||||||
9. `af0b0a5` - Cleanup: Remove dead nav items, orphaned i18n keys
|
|
||||||
10. `0c7a69f` - Add calendar filter tabs and grading analytics
|
|
||||||
11. `13fce9d` - Fix UX: Suppress PWA, respect onboarding, silence WS
|
|
||||||
12. `678a4e9` - Compliance: Remove emojis, add touch targets, reduced-motion
|
|
||||||
13. `7461f76` - i18n: Replace hardcoded English with German
|
|
||||||
|
|
||||||
## Unresolved Issues or Risks
|
|
||||||
|
|
||||||
### Known Limitations
|
|
||||||
1. **Auth is session-based** - No real NextAuth.js integration yet
|
|
||||||
2. **WebSocket (real-time collaboration)** - Mini-service not running, falls back silently
|
|
||||||
3. **UnifiedGradingPanel** - Uses assessments API (not a dedicated grades API); data transformation bridges the gap
|
|
||||||
4. **Bulk results API** - `/api/assessments/bulk-results` may not exist yet
|
|
||||||
5. **Student/parent nav** - Some declared items may still be filtered by role-access
|
|
||||||
|
|
||||||
### Priority Recommendations for Next Phase
|
|
||||||
1. **Page Tab Consolidation** - Implement tabs within Classes page (Overview, Students, Subjects, Staff, Timetable, Seating, Attendance, Competencies)
|
|
||||||
2. **Student Detail Page** - Consolidate student workflows into tabs (Overview, Progress, Assessments, Reports, Attendance, Wellbeing, Goals, Notebook, Portfolio, Support)
|
|
||||||
3. **School Policy DB Records** - Move configurable policies into database-backed records
|
|
||||||
4. **PDF Report Generation** - Implement @react-pdf/renderer for report cards
|
|
||||||
5. **Digital Notebook Enhancement** - Ensure stylus/pen support works on iPad
|
|
||||||
6. **Server-side Authorization Tests** - Cross-school IDOR attempt tests
|
|
||||||
7. **Docker Support** - Dockerfile for easy self-hosted deployment
|
|
||||||
Reference in new issue
Block a user