185 lines
8.7 KiB
TypeScript
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, '') },
|
|
{ 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>
|
|
</>
|
|
);
|
|
}
|