skills/omerakben/omer-akben/canvas-component

canvas-component

SKILL.md

Canvas Component Development Skill

When to Use

Use this skill when:

  • Creating Canvas panel or container components
  • Adding Monaco editor features
  • Building code preview/execution UI
  • Implementing split-view layouts
  • Adding toolbar actions (run, download, share)

Component Architecture

components/canvas/
├── canvas-container.tsx          # Root container with state
├── canvas-panel.tsx              # Full panel with editor + preview
├── canvas-editor.tsx             # Monaco wrapper
├── canvas-preview.tsx            # Execution preview
├── canvas-toolbar.tsx            # Actions toolbar
├── canvas-editor-error-boundary.tsx  # Error recovery
└── index.ts                      # Barrel exports

State Management (Zustand)

Canvas Store Pattern

import { create } from 'zustand';
import type { CanvasState, CanvasType, ViewMode } from '@/lib/canvas/types';

interface CanvasStore extends CanvasState {
  // Actions
  openCanvas: (config: CanvasConfig) => void;
  closeCanvas: () => void;
  updateContent: (content: string) => void;
  setViewMode: (mode: ViewMode) => void;
  undo: () => void;
  redo: () => void;

  // Generation
  startGeneration: (prompt: string) => void;
  completeGeneration: (content: string) => void;
}

export const useCanvasStore = create<CanvasStore>((set, get) => ({
  // Initial state
  isOpen: false,
  content: '',
  type: 'code',
  title: 'Untitled',
  language: 'python',
  viewMode: 'split',
  history: [],
  historyIndex: -1,
  generationPrompt: '',
  isGenerating: false,

  openCanvas: (config) => set({
    isOpen: true,
    type: config.type,
    title: config.title,
    language: config.language || getDefaultLanguage(config.type),
    content: config.initialContent || '',
    generationPrompt: config.generationPrompt || '',
    viewMode: 'split',
    history: [config.initialContent || ''],
    historyIndex: 0,
  }),

  updateContent: (content) => {
    const { history, historyIndex } = get();
    const newHistory = [...history.slice(0, historyIndex + 1), content];
    set({
      content,
      history: newHistory,
      historyIndex: newHistory.length - 1,
    });
  },
  // ...
}));

Monaco Editor Wrapper

Basic Setup

'use client';

import { useRef, useCallback } from 'react';
import MonacoEditor, { OnMount, OnChange } from '@monaco-editor/react';
import { getMonacoLanguage } from '@/lib/canvas/types';
import { CanvasEditorErrorBoundary } from './canvas-editor-error-boundary';

interface CanvasEditorProps {
  content: string;
  language: string;
  onChange: (value: string) => void;
  readOnly?: boolean;
  height?: string;
}

export function CanvasEditor({
  content,
  language,
  onChange,
  readOnly = false,
  height = '100%',
}: CanvasEditorProps) {
  const editorRef = useRef<any>(null);

  const handleMount: OnMount = (editor, monaco) => {
    editorRef.current = editor;

    // Configure Monaco for educational use
    monaco.editor.defineTheme('canvas-theme', {
      base: 'vs-dark',
      inherit: true,
      rules: [],
      colors: {
        'editor.background': '#1a1a1a',
      },
    });

    editor.updateOptions({
      fontSize: 14,
      lineHeight: 22,
      minimap: { enabled: false },
      scrollBeyondLastLine: false,
      wordWrap: 'on',
      tabSize: 4,
      insertSpaces: true,
    });
  };

  const handleChange: OnChange = (value) => {
    onChange(value || '');
  };

  return (
    <CanvasEditorErrorBoundary>
      <MonacoEditor
        height={height}
        language={getMonacoLanguage(language)}
        value={content}
        onChange={handleChange}
        onMount={handleMount}
        theme="canvas-theme"
        options={{
          readOnly,
          automaticLayout: true,
        }}
        loading={<EditorSkeleton />}
      />
    </CanvasEditorErrorBoundary>
  );
}

Error Boundary

'use client';

import { Component, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { resetMonacoLoader } from '@/lib/canvas/monaco-loader';

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class CanvasEditorErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  handleReset = () => {
    resetMonacoLoader();
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
          <AlertTriangle className="h-8 w-8 text-destructive" />
          <div>
            <h3 className="font-medium">Editor failed to load</h3>
            <p className="text-sm text-muted-foreground mt-1">
              This usually resolves after a page refresh.
            </p>
          </div>
          <Button onClick={this.handleReset} size="sm">
            <RefreshCw className="h-4 w-4 mr-2" />
            Try Again
          </Button>
        </div>
      );
    }

    return this.props.children;
  }
}

Split View Panel

Resizable Layout

'use client';

import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { CanvasEditor } from './canvas-editor';
import { CanvasPreview } from './canvas-preview';
import { CanvasToolbar } from './canvas-toolbar';
import type { ViewMode } from '@/lib/canvas/types';

interface CanvasPanelProps {
  content: string;
  language: string;
  viewMode: ViewMode;
  onContentChange: (content: string) => void;
  onViewModeChange: (mode: ViewMode) => void;
  onRun: () => void;
}

export function CanvasPanel({
  content,
  language,
  viewMode,
  onContentChange,
  onViewModeChange,
  onRun,
}: CanvasPanelProps) {
  return (
    <div className="flex flex-col h-full">
      <CanvasToolbar
        viewMode={viewMode}
        onViewModeChange={onViewModeChange}
        onRun={onRun}
        language={language}
      />

      <div className="flex-1 min-h-0">
        {viewMode === 'split' ? (
          <ResizablePanelGroup direction="horizontal">
            <ResizablePanel defaultSize={50} minSize={30}>
              <CanvasEditor
                content={content}
                language={language}
                onChange={onContentChange}
              />
            </ResizablePanel>
            <ResizableHandle withHandle />
            <ResizablePanel defaultSize={50} minSize={30}>
              <CanvasPreview
                content={content}
                language={language}
              />
            </ResizablePanel>
          </ResizablePanelGroup>
        ) : viewMode === 'code' ? (
          <CanvasEditor
            content={content}
            language={language}
            onChange={onContentChange}
          />
        ) : (
          <CanvasPreview
            content={content}
            language={language}
          />
        )}
      </div>
    </div>
  );
}

Toolbar Actions

Standard Toolbar

'use client';

import { Play, Download, Copy, Code, Eye, Columns2, Undo, Redo } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { canExecute, getFileExtension } from '@/lib/canvas/types';
import type { ViewMode } from '@/lib/canvas/types';

interface CanvasToolbarProps {
  viewMode: ViewMode;
  onViewModeChange: (mode: ViewMode) => void;
  onRun: () => void;
  onUndo?: () => void;
  onRedo?: () => void;
  canUndo?: boolean;
  canRedo?: boolean;
  language: string;
  content?: string;
  isRunning?: boolean;
}

export function CanvasToolbar({
  viewMode,
  onViewModeChange,
  onRun,
  onUndo,
  onRedo,
  canUndo,
  canRedo,
  language,
  content,
  isRunning,
}: CanvasToolbarProps) {
  const showRunButton = canExecute(language);

  const handleDownload = () => {
    const blob = new Blob([content || ''], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `code${getFileExtension(language)}`;
    a.click();
    URL.revokeObjectURL(url);
  };

  const handleCopy = async () => {
    await navigator.clipboard.writeText(content || '');
    // Show toast
  };

  return (
    <div className="flex items-center justify-between px-2 py-1.5 border-b bg-muted/50">
      <div className="flex items-center gap-1">
        {/* View Mode Toggle */}
        <ToggleGroup
          type="single"
          value={viewMode}
          onValueChange={(v) => v && onViewModeChange(v as ViewMode)}
          size="sm"
        >
          <ToggleGroupItem value="code" aria-label="Code only">
            <Code className="h-4 w-4" />
          </ToggleGroupItem>
          <ToggleGroupItem value="split" aria-label="Split view">
            <Columns2 className="h-4 w-4" />
          </ToggleGroupItem>
          <ToggleGroupItem value="preview" aria-label="Preview only">
            <Eye className="h-4 w-4" />
          </ToggleGroupItem>
        </ToggleGroup>

        <div className="w-px h-4 bg-border mx-1" />

        {/* Undo/Redo */}
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="icon"
              className="h-7 w-7"
              onClick={onUndo}
              disabled={!canUndo}
            >
              <Undo className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Undo (Ctrl+Z)</TooltipContent>
        </Tooltip>

        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="icon"
              className="h-7 w-7"
              onClick={onRedo}
              disabled={!canRedo}
            >
              <Redo className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Redo (Ctrl+Shift+Z)</TooltipContent>
        </Tooltip>
      </div>

      <div className="flex items-center gap-1">
        <Tooltip>
          <TooltipTrigger asChild>
            <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
              <Copy className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Copy code</TooltipContent>
        </Tooltip>

        <Tooltip>
          <TooltipTrigger asChild>
            <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleDownload}>
              <Download className="h-4 w-4" />
            </Button>
          </TooltipTrigger>
          <TooltipContent>Download</TooltipContent>
        </Tooltip>

        {showRunButton && (
          <Button
            size="sm"
            className="ml-2 gap-1"
            onClick={onRun}
            disabled={isRunning}
          >
            <Play className="h-3 w-3" />
            {isRunning ? 'Running...' : 'Run'}
          </Button>
        )}
      </div>
    </div>
  );
}

Keyboard Shortcuts

Hook Implementation

import { useHotkeys } from 'react-hotkeys-hook';

function CanvasWithShortcuts() {
  const { content, updateContent, undo, redo, canUndo, canRedo } = useCanvasStore();

  // Run code
  useHotkeys('mod+enter', () => handleRun(), { enableOnFormTags: true });

  // Undo/Redo (Monaco handles internal, this is for store)
  useHotkeys('mod+z', () => undo(), { enabled: canUndo });
  useHotkeys('mod+shift+z', () => redo(), { enabled: canRedo });

  // Toggle view modes
  useHotkeys('mod+1', () => setViewMode('code'));
  useHotkeys('mod+2', () => setViewMode('split'));
  useHotkeys('mod+3', () => setViewMode('preview'));
}

Educational Context Display

Learning Objective Header

interface EducationalContextProps {
  context?: {
    topic?: string;
    difficulty?: 'beginner' | 'intermediate' | 'advanced';
    learningObjective?: string;
  };
}

function EducationalContextHeader({ context }: EducationalContextProps) {
  if (!context?.learningObjective) return null;

  const difficultyColors = {
    beginner: 'bg-green-100 text-green-800',
    intermediate: 'bg-amber-100 text-amber-800',
    advanced: 'bg-red-100 text-red-800',
  };

  return (
    <div className="px-4 py-2 border-b bg-muted/30">
      <div className="flex items-center gap-2 text-sm">
        {context.difficulty && (
          <span className={cn(
            'px-2 py-0.5 rounded-full text-xs font-medium',
            difficultyColors[context.difficulty]
          )}>
            {context.difficulty}
          </span>
        )}
        {context.topic && (
          <span className="text-muted-foreground">
            {context.topic}
          </span>
        )}
      </div>
      <p className="text-sm mt-1">{context.learningObjective}</p>
    </div>
  );
}

Accessibility Requirements

WCAG 2.1 AA Checklist

  • Focus visible on all interactive elements
  • Keyboard navigation for all actions
  • Screen reader labels for icons
  • Color contrast 4.5:1 minimum
  • Announced status changes (execution results)
  • Skip links for editor navigation
// Example: Screen reader announcement
import { useEffect } from 'react';

function useAnnounce() {
  const announce = (message: string) => {
    const el = document.createElement('div');
    el.setAttribute('role', 'status');
    el.setAttribute('aria-live', 'polite');
    el.className = 'sr-only';
    el.textContent = message;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 1000);
  };
  return announce;
}

// Usage
const announce = useAnnounce();
announce('Code executed successfully');

Testing Checklist

  • Monaco loads without errors
  • Split view resizing works
  • View mode toggles correctly
  • Keyboard shortcuts function
  • Undo/redo maintains history
  • Copy/download work
  • Mobile responsive
  • Error boundary catches Monaco failures
  • Educational context displays
  • Accessibility requirements met
Weekly Installs
3
GitHub Stars
1
First Seen
14 days ago
Installed on
opencode3
gemini-cli3
codebuddy3
github-copilot3
codex3
kimi-cli3