canvas-website/src/shapes/MarkdownShapeUtil.tsx

528 lines
16 KiB
TypeScript

import React, { useState, useCallback, useRef, useEffect } from 'react'
import {
MDXEditor,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
linkPlugin,
linkDialogPlugin,
imagePlugin,
tablePlugin,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
toolbarPlugin,
BoldItalicUnderlineToggles,
UndoRedo,
BlockTypeSelect,
CreateLink,
InsertTable,
ListsToggle,
Separator,
DiffSourceToggleWrapper,
type MDXEditorMethods,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from '@tldraw/tldraw'
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
import { usePinnedToView } from '../hooks/usePinnedToView'
import { useMaximize } from '../hooks/useMaximize'
export type IMarkdownShape = TLBaseShape<
'Markdown',
{
w: number
h: number
text: string
pinnedToView: boolean
tags: string[]
}
>
export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
static type = 'Markdown' as const
// Markdown theme color: Teal
static readonly PRIMARY_COLOR = "#14b8a6"
getDefaultProps(): IMarkdownShape['props'] {
return {
w: 650,
h: 400,
text: '',
pinnedToView: false,
tags: ['markdown'],
}
}
component(shape: IMarkdownShape) {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isMinimized, setIsMinimized] = useState(false)
const [isToolbarMinimized, setIsToolbarMinimized] = useState(false)
const editorRef = useRef<MDXEditorMethods>(null)
// Dark mode detection with reactive updates
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof document !== 'undefined') {
return document.documentElement.classList.contains('dark')
}
return false
})
// Listen for dark mode changes
useEffect(() => {
if (typeof document === 'undefined') return
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'))
}
})
})
observer.observe(document.documentElement, { attributes: true })
return () => observer.disconnect()
}, [])
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
// Use the maximize hook for fullscreen functionality
const { isMaximized, toggleMaximize } = useMaximize({
editor: this.editor,
shapeId: shape.id,
currentW: shape.props.w,
currentH: shape.props.h,
shapeType: 'Markdown',
})
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
const handleChange = useCallback((newText: string) => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: newText,
},
})
}, [shape.id, shape.props])
// Sync external changes to editor
useEffect(() => {
if (editorRef.current) {
const currentMarkdown = editorRef.current.getMarkdown()
if (currentMarkdown !== shape.props.text) {
editorRef.current.setMarkdown(shape.props.text || '')
}
}
}, [shape.props.text])
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="Markdown"
primaryColor={MarkdownShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div
style={{
width: '100%',
height: '100%',
backgroundColor: isDarkMode ? '#1a1a1a' : '#FFFFFF',
pointerEvents: 'all',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<MDXEditor
ref={editorRef}
markdown={shape.props.text || ''}
onChange={handleChange}
className={isDarkMode ? 'dark-theme' : ''}
contentEditableClassName="mdx-editor-content"
plugins={[
// Core formatting
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
linkDialogPlugin(),
// Tables
tablePlugin(),
// Code blocks with syntax highlighting
codeBlockPlugin({ defaultCodeBlockLanguage: 'javascript' }),
codeMirrorPlugin({
codeBlockLanguages: {
js: 'JavaScript',
javascript: 'JavaScript',
ts: 'TypeScript',
typescript: 'TypeScript',
jsx: 'JSX',
tsx: 'TSX',
css: 'CSS',
html: 'HTML',
json: 'JSON',
python: 'Python',
py: 'Python',
bash: 'Bash',
sh: 'Shell',
sql: 'SQL',
md: 'Markdown',
yaml: 'YAML',
go: 'Go',
rust: 'Rust',
'': 'Plain Text',
}
}),
// Images (with placeholder for now)
imagePlugin({
imageUploadHandler: async () => {
// Return a placeholder - can be extended to support actual uploads
return Promise.resolve('https://via.placeholder.com/400x300')
},
}),
// Markdown shortcuts (type # for heading, * for list, etc.)
markdownShortcutPlugin(),
// Source mode toggle (rich-text vs raw markdown)
diffSourcePlugin({
viewMode: 'rich-text',
diffMarkdown: shape.props.text || '',
}),
// Toolbar
toolbarPlugin({
toolbarContents: () => (
isToolbarMinimized ? (
// When minimized, show nothing - the toggle button is rendered outside
<></>
) : (
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '4px' }}>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<Separator />
<BlockTypeSelect />
<Separator />
<ListsToggle />
<Separator />
<CreateLink />
<InsertTable />
<Separator />
<DiffSourceToggleWrapper>
<></>
</DiffSourceToggleWrapper>
</div>
)
)
}),
]}
/>
{/* Toolbar toggle button - fixed position, doesn't move */}
<button
onClick={() => setIsToolbarMinimized(!isToolbarMinimized)}
style={{
position: 'absolute',
top: '6px',
right: '8px',
background: isDarkMode ? 'rgba(42, 42, 42, 0.9)' : 'rgba(249, 250, 251, 0.9)',
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
cursor: 'pointer',
padding: '4px 6px',
fontSize: '10px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isDarkMode ? '#e4e4e4' : '#374151',
zIndex: 10,
}}
title={isToolbarMinimized ? "Show toolbar" : "Hide toolbar"}
>
{isToolbarMinimized ? '▼' : '▲'}
</button>
</div>
{/* Custom styles for the MDXEditor */}
<style>{`
.mdxeditor {
height: 100%;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.mdxeditor [role="toolbar"] {
flex-shrink: 0;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
padding: 4px 6px;
padding-right: 36px; /* Space for toggle button */
gap: 1px;
flex-wrap: nowrap;
overflow: hidden;
display: ${isToolbarMinimized ? 'none' : 'flex'};
}
.mdxeditor [role="toolbar"] button {
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
}
.mdxeditor [role="toolbar"] button:hover {
background: #e5e7eb;
}
.mdxeditor [role="toolbar"] button[data-state="on"] {
background: ${MarkdownShape.PRIMARY_COLOR}20;
color: ${MarkdownShape.PRIMARY_COLOR};
}
.mdxeditor .mdxeditor-root-contenteditable {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 16px;
min-height: 0;
}
/* Custom scrollbar styling - vertical only, auto-hide */
.mdxeditor .mdxeditor-root-contenteditable::-webkit-scrollbar {
width: 8px;
height: 0; /* No horizontal scrollbar */
}
.mdxeditor .mdxeditor-root-contenteditable::-webkit-scrollbar-track {
background: transparent;
}
.mdxeditor .mdxeditor-root-contenteditable::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.mdxeditor .mdxeditor-root-contenteditable::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Firefox scrollbar */
.mdxeditor .mdxeditor-root-contenteditable {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.mdx-editor-content {
min-height: 100%;
height: 100%;
outline: none;
}
/* Ensure the editor fills the full available height */
.mdxeditor .mdxeditor-root-contenteditable > div {
min-height: 100%;
}
.mdx-editor-content h1 {
font-size: 1.75em;
font-weight: 700;
margin: 0.5em 0 0.25em;
color: #111827;
}
.mdx-editor-content h2 {
font-size: 1.5em;
font-weight: 600;
margin: 0.5em 0 0.25em;
color: #1f2937;
}
.mdx-editor-content h3 {
font-size: 1.25em;
font-weight: 600;
margin: 0.5em 0 0.25em;
color: #374151;
}
.mdx-editor-content p {
margin: 0.5em 0;
line-height: 1.6;
}
.mdx-editor-content ul, .mdx-editor-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.mdx-editor-content li {
margin: 0.25em 0;
}
.mdx-editor-content blockquote {
border-left: 3px solid ${MarkdownShape.PRIMARY_COLOR};
margin: 0.5em 0;
padding: 0.5em 1em;
background: #f3f4f6;
border-radius: 0 4px 4px 0;
}
.mdx-editor-content code {
background: #f3f4f6;
padding: 0.15em 0.4em;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 0.9em;
}
.mdx-editor-content pre {
background: #1e1e2e;
color: #cdd6f4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 0.5em 0;
}
.mdx-editor-content pre code {
background: none;
padding: 0;
color: inherit;
}
.mdx-editor-content a {
color: ${MarkdownShape.PRIMARY_COLOR};
text-decoration: underline;
}
.mdx-editor-content table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
}
.mdx-editor-content th, .mdx-editor-content td {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
}
.mdx-editor-content th {
background: #f9fafb;
font-weight: 600;
}
.mdx-editor-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1em 0;
}
.mdx-editor-content img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
/* Source mode styling */
.mdxeditor-source-editor {
flex: 1;
overflow: auto;
}
.mdxeditor-source-editor .cm-editor {
height: 100%;
}
.mdxeditor-source-editor .cm-scroller {
padding: 12px 16px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
/* Diff source toggle button styling */
.mdxeditor [role="toolbar"] select {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #e5e7eb;
background: white;
font-size: 12px;
cursor: pointer;
}
/* Block type select */
.mdxeditor [data-radix-popper-content-wrapper] {
z-index: 100000 !important;
}
`}</style>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
indicator(shape: IMarkdownShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
override onDoubleClick = (shape: IMarkdownShape) => {
// Focus the editor on double-click
const editorElement = document.querySelector(`[data-shape-id="${shape.id}"] .mdxeditor [contenteditable="true"]`) as HTMLElement
editorElement?.focus()
}
}