changemaker.lite/admin/src/components/docs/MobileFormattingToolbar.tsx

185 lines
8.7 KiB
TypeScript

import { useState, useCallback } from 'react';
import { Button, Drawer, List, theme } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
CodeOutlined,
LinkOutlined,
FontSizeOutlined,
EllipsisOutlined,
SaveOutlined,
} from '@ant-design/icons';
import {
insertAtCursor,
insertBlock,
cycleHeading,
applyResult,
type TextareaInsertResult,
} from '@/utils/textareaSnippets';
export type InsertRequestType = 'video-card' | 'photo-insert' | 'donate-button' | 'pricing-table' | 'product-card' | 'ad-insert' | 'scheduling-poll';
interface MobileFormattingToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
dirty: boolean;
saving: boolean;
onContentChange: (value: string) => void;
onSave: () => void;
onInsertRequest?: (type: InsertRequestType) => void;
}
interface SnippetDef {
id: string;
label: string;
group: string;
run?: (ta: HTMLTextAreaElement) => TextareaInsertResult;
insertType?: InsertRequestType;
}
const MORE_SNIPPETS: SnippetDef[] = [
// Formatting
{ id: 'strikethrough', label: 'Strikethrough', group: 'Formatting', run: (ta) => insertAtCursor(ta, '~~', '~~') },
{ id: 'highlight', label: 'Highlight', group: 'Formatting', run: (ta) => insertAtCursor(ta, '==', '==') },
{ id: 'kbd', label: 'Keyboard Key', group: 'Formatting', run: (ta) => insertAtCursor(ta, '++', '++') },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'Headings', run: (ta) => insertBlock(ta, '# $CURSOR') },
{ id: 'h2', label: 'Heading 2', group: 'Headings', run: (ta) => insertBlock(ta, '## $CURSOR') },
{ id: 'h3', label: 'Heading 3', group: 'Headings', run: (ta) => insertBlock(ta, '### $CURSOR') },
{ id: 'h4', label: 'Heading 4', group: 'Headings', run: (ta) => insertBlock(ta, '#### $CURSOR') },
// Admonitions
{ id: 'note', label: 'Note', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! note "Title"\n Content here') },
{ id: 'warning', label: 'Warning', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! warning "Title"\n Content here') },
{ id: 'tip', label: 'Tip', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! tip "Title"\n Content here') },
{ id: 'info', label: 'Info', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! info "Title"\n Content here') },
{ id: 'danger', label: 'Danger', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! danger "Title"\n Content here') },
{ id: 'success', label: 'Success', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! success "Title"\n Content here') },
{ id: 'collapsible', label: 'Collapsible', group: 'Admonitions', run: (ta) => insertBlock(ta, '???+ note "Title"\n Content here') },
// Code
{ id: 'code-block', label: 'Code Block', group: 'Code', run: (ta) => insertBlock(ta, '```python\n$CURSOR\n```') },
{ id: 'code-annotated', label: 'Annotated Code', group: 'Code', run: (ta) => insertBlock(ta, '```python\ncode # (1)!\n```\n\n1. Annotation') },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'Code', run: (ta) => insertBlock(ta, '```mermaid\ngraph LR\n A --> B\n```') },
// Insert — text snippets
{ id: 'image', label: 'Image', group: 'Insert', run: (ta) => insertBlock(ta, '![Alt text](image.png)') },
{ id: 'table', label: 'Table', group: 'Insert', run: (ta) => insertBlock(ta, '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |') },
{ id: 'tasklist', label: 'Task List', group: 'Insert', run: (ta) => insertBlock(ta, '- [ ] Task 1\n- [ ] Task 2\n- [x] Done') },
{ id: 'tabs', label: 'Tabs', group: 'Insert', run: (ta) => insertBlock(ta, '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content') },
{ id: 'button', label: 'Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button }') },
{ id: 'button-primary', label: 'Primary Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button .md-button--primary }') },
{ id: 'icon', label: 'Material Icon', group: 'Insert', run: (ta) => insertBlock(ta, ':material-icon-name:') },
{ id: 'math-block', label: 'Math Block', group: 'Insert', run: (ta) => insertBlock(ta, '$$\n$CURSOR\n$$') },
{ id: 'footnote', label: 'Footnote', group: 'Insert', run: (ta) => insertBlock(ta, '[^1]\n\n[^1]: Text') },
{ id: 'def-list', label: 'Definition List', group: 'Insert', run: (ta) => insertBlock(ta, 'Term\n: Definition') },
{ id: 'hr', label: 'Horizontal Rule', group: 'Insert', run: (ta) => insertBlock(ta, '---') },
// Insert — modal-based (open picker)
{ id: 'video-card', label: 'Video Card', group: 'Media & Widgets', insertType: 'video-card' },
{ id: 'photo-insert', label: 'Photo', group: 'Media & Widgets', insertType: 'photo-insert' },
{ id: 'donate-button', label: 'Donate Button', group: 'Media & Widgets', insertType: 'donate-button' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'Media & Widgets', insertType: 'pricing-table' },
{ id: 'product-card', label: 'Product Card', group: 'Media & Widgets', insertType: 'product-card' },
{ id: 'ad-insert', label: 'Ad', group: 'Media & Widgets', insertType: 'ad-insert' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'Media & Widgets', insertType: 'scheduling-poll' },
];
const GROUPS = [...new Set(MORE_SNIPPETS.map(s => s.group))];
export function MobileFormattingToolbar({
textareaRef,
dirty,
saving,
onContentChange,
onSave,
onInsertRequest,
}: MobileFormattingToolbarProps) {
const { token } = theme.useToken();
const [drawerOpen, setDrawerOpen] = useState(false);
const run = useCallback((fn: (ta: HTMLTextAreaElement) => TextareaInsertResult) => {
const ta = textareaRef.current;
if (!ta) return;
applyResult(ta, fn(ta), onContentChange);
}, [textareaRef, onContentChange]);
const btnStyle: React.CSSProperties = { minWidth: 44, height: 44 };
return (
<>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '6px 8px',
paddingBottom: `max(6px, env(safe-area-inset-bottom))`,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}
>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '**', '**'))} style={btnStyle} />
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '*', '*'))} style={btnStyle} />
<Button type="text" size="small" icon={<FontSizeOutlined />} onClick={() => run(cycleHeading)} style={btnStyle} />
<Button type="text" size="small" icon={<LinkOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '[', '](url)'))} style={btnStyle} />
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '`', '`'))} style={btnStyle} />
<Button type="text" size="small" icon={<EllipsisOutlined />} onClick={() => setDrawerOpen(true)} style={btnStyle} />
<div style={{ flex: 1 }} />
<Button
type={dirty ? 'primary' : 'default'}
size="small"
icon={<SaveOutlined />}
onClick={onSave}
loading={saving}
disabled={!dirty}
style={{ height: 44 }}
>
Save
</Button>
</div>
<Drawer
title="Insert Snippet"
placement="bottom"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
height="60%"
styles={{ body: { padding: 0 } }}
>
{GROUPS.map(group => (
<div key={group}>
<div style={{ padding: '10px 16px 2px', fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{group}
</div>
<List
size="small"
dataSource={MORE_SNIPPETS.filter(s => s.group === group)}
renderItem={(item) => (
<List.Item
style={{ padding: '10px 16px', cursor: 'pointer' }}
onClick={() => {
if (item.insertType) {
onInsertRequest?.(item.insertType);
setDrawerOpen(false);
} else if (item.run) {
run(item.run);
setDrawerOpen(false);
setTimeout(() => textareaRef.current?.focus(), 300);
}
}}
>
{item.label}
</List.Item>
)}
/>
</div>
))}
</Drawer>
</>
);
}