From 9448d7d4f349961ec4adc6dedff0a2a418c1b83d Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 19 Mar 2026 23:30:33 +0800 Subject: [PATCH] v0.1.4-p3 --- .env.prod.example | 2 + backend/core/settings.py | 4 + backend/main.py | 101 ++++++++- backend/schemas/platform.py | 1 + backend/services/platform_service.py | 23 ++ docker-compose.prod.yml | 2 + frontend/src/App.css | 81 +++++++ .../markdown/MarkdownLiteEditor.tsx | 189 ++++++++++++++++ .../modules/dashboard/BotDashboardModule.css | 15 +- .../modules/dashboard/BotDashboardModule.tsx | 204 +++++++++++++----- .../modules/onboarding/BotWizardModule.tsx | 43 +++- .../components/TemplateManagerModal.tsx | 9 +- frontend/src/modules/platform/types.ts | 1 + frontend/src/types/bot.ts | 1 + frontend/src/utils/systemTimezones.ts | 28 +++ 15 files changed, 632 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/markdown/MarkdownLiteEditor.tsx create mode 100644 frontend/src/utils/systemTimezones.ts diff --git a/.env.prod.example b/.env.prod.example index 08d6657..c2b8c4c 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -44,6 +44,8 @@ REDIS_DEFAULT_TTL=60 # Chat history page size for upward lazy loading (per request) CHAT_PULL_PAGE_SIZE=60 +COMMAND_AUTO_UNLOCK_SECONDS=10 +DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai # Panel access protection PANEL_ACCESS_PASSWORD=change_me_panel_password diff --git a/backend/core/settings.py b/backend/core/settings.py index 42754ba..33c1295 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -181,6 +181,10 @@ DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30, DEFAULT_UPLOAD_MAX_MB: Final[int] = 100 DEFAULT_PAGE_SIZE: Final[int] = 10 DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 +DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600) +DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str( + os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai" +).strip() or "Asia/Shanghai" DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = ( ".pdf", ".doc", diff --git a/backend/main.py b/backend/main.py index 02a14ce..586754b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,10 +6,12 @@ import os import re import shutil import tempfile +import time import zipfile from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from urllib.parse import quote, unquote +from zoneinfo import ZoneInfo import httpx from pydantic import BaseModel @@ -36,6 +38,7 @@ from core.settings import ( DATABASE_URL_DISPLAY, AGENT_MD_TEMPLATES_FILE, DEFAULT_AGENTS_MD, + DEFAULT_BOT_SYSTEM_TIMEZONE, DEFAULT_IDENTITY_MD, DEFAULT_SOUL_MD, DEFAULT_TOOLS_MD, @@ -128,6 +131,7 @@ class BotCreateRequest(BaseModel): cpu_cores: float = 1.0 memory_mb: int = 1024 storage_gb: int = 10 + system_timezone: Optional[str] = None soul_md: Optional[str] = None agents_md: Optional[str] = None user_md: Optional[str] = None @@ -156,6 +160,7 @@ class BotUpdateRequest(BaseModel): cpu_cores: Optional[float] = None memory_mb: Optional[int] = None storage_gb: Optional[int] = None + system_timezone: Optional[str] = None soul_md: Optional[str] = None agents_md: Optional[str] = None user_md: Optional[str] = None @@ -675,10 +680,14 @@ def get_system_defaults(): "download_extensions": list(platform_settings.workspace_download_extensions), "allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions), }, + "bot": { + "system_timezone": _get_default_system_timezone(), + }, "loading_page": platform_settings.loading_page.model_dump(), "chat": { "pull_page_size": platform_settings.chat_pull_page_size, "page_size": platform_settings.page_size, + "command_auto_unlock_seconds": platform_settings.command_auto_unlock_seconds, }, "topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES, "speech": { @@ -1159,6 +1168,35 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]: return rows +def _get_default_system_timezone() -> str: + value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai" + try: + ZoneInfo(value) + return value + except Exception: + return "Asia/Shanghai" + + +def _normalize_system_timezone(raw: Any) -> str: + value = str(raw or "").strip() + if not value: + return _get_default_system_timezone() + try: + ZoneInfo(value) + except Exception as exc: + raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc + return value + + +def _resolve_bot_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]: + env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id)) + try: + env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ")) + except ValueError: + env_params["TZ"] = _get_default_system_timezone() + return env_params + + _MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") @@ -1295,6 +1333,7 @@ def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: config_data = _read_bot_config(bot.id) + env_params = _resolve_bot_env_params(bot.id) provider_name = "" provider_cfg: Dict[str, Any] = {} @@ -1326,7 +1365,6 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) resources = _read_bot_resources(bot.id, config_data=config_data) - return { "llm_provider": llm_provider, "llm_model": llm_model, @@ -1338,6 +1376,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: "cpu_cores": resources["cpu_cores"], "memory_mb": resources["memory_mb"], "storage_gb": resources["storage_gb"], + "system_timezone": env_params.get("TZ") or _get_default_system_timezone(), "send_progress": send_progress, "send_tool_hints": send_tool_hints, "soul_md": soul_md, @@ -1370,6 +1409,7 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: "cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0), "memory_mb": _safe_int(runtime.get("memory_mb"), 1024), "storage_gb": _safe_int(runtime.get("storage_gb"), 10), + "system_timezone": str(runtime.get("system_timezone") or _get_default_system_timezone()), "send_progress": bool(runtime.get("send_progress")), "send_tool_hints": bool(runtime.get("send_tool_hints")), "soul_md": runtime.get("soul_md") or "", @@ -1400,6 +1440,24 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]: } +_AGENT_LOOP_READY_MARKER = "Agent loop started" + + +async def _wait_for_agent_loop_ready( + bot_id: str, + timeout_seconds: float = 12.0, + poll_interval_seconds: float = 0.5, +) -> bool: + deadline = time.monotonic() + max(1.0, timeout_seconds) + marker = _AGENT_LOOP_READY_MARKER.lower() + while time.monotonic() < deadline: + logs = docker_manager.get_recent_logs(bot_id, tail=200) + if any(marker in str(line or "").lower() for line in logs): + return True + await asyncio.sleep(max(0.1, poll_interval_seconds)) + return False + + def _sync_workspace_channels( session: Session, bot_id: str, @@ -2302,6 +2360,12 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session if not docker_manager.has_image(payload.image_tag): raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}") + normalized_env_params = _normalize_env_params(payload.env_params) + try: + normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + bot = BotInstance( id=normalized_bot_id, name=payload.name, @@ -2315,7 +2379,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session session.commit() session.refresh(bot) resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) - _write_env_store(normalized_bot_id, _normalize_env_params(payload.env_params)) + _write_env_store(normalized_bot_id, normalized_env_params) _sync_workspace_channels( session, normalized_bot_id, @@ -2477,6 +2541,13 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}") env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None + system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None + normalized_system_timezone: Optional[str] = None + if system_timezone is not None: + try: + normalized_system_timezone = _normalize_system_timezone(system_timezone) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc runtime_overrides: Dict[str, Any] = {} update_data.pop("tools_config", None) if isinstance(update_data, dict) else None @@ -2535,8 +2606,13 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend session.add(bot) session.commit() session.refresh(bot) - if env_params is not None: - _write_env_store(bot_id, _normalize_env_params(env_params)) + if env_params is not None or normalized_system_timezone is not None: + next_env_params = _resolve_bot_env_params(bot_id) + if env_params is not None: + next_env_params = _normalize_env_params(env_params) + if normalized_system_timezone is not None: + next_env_params["TZ"] = normalized_system_timezone + _write_env_store(bot_id, next_env_params) global_delivery_override: Optional[Dict[str, Any]] = None if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides: global_delivery_override = {} @@ -2565,7 +2641,8 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") _sync_workspace_channels(session, bot_id) runtime_snapshot = _read_bot_runtime_snapshot(bot) - env_params = _read_env_store(bot_id) + env_params = _resolve_bot_env_params(bot_id) + _write_env_store(bot_id, env_params) success = docker_manager.start_bot( bot_id, image_tag=bot.image_tag, @@ -2591,6 +2668,20 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): status_code=500, detail="Bot container failed shortly after startup. Check bot logs/config.", ) + agent_loop_ready = await _wait_for_agent_loop_ready(bot_id) + if not agent_loop_ready: + docker_manager.stop_bot(bot_id) + bot.docker_status = "STOPPED" + if str(bot.current_state or "").upper() not in {"ERROR"}: + bot.current_state = "IDLE" + bot.updated_at = datetime.utcnow() + session.add(bot) + session.commit() + _invalidate_bot_detail_cache(bot_id) + raise HTTPException( + status_code=500, + detail="Bot启动异常:容器已启动,但 Agent loop 未正常就绪,请检查 Bot 日志或 MCP 配置。", + ) session.add(bot) record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}") session.commit() diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py index 54f1cbb..4fddb04 100644 --- a/backend/schemas/platform.py +++ b/backend/schemas/platform.py @@ -12,6 +12,7 @@ class LoadingPageSettings(BaseModel): class PlatformSettingsPayload(BaseModel): page_size: int = Field(default=10, ge=1, le=100) chat_pull_page_size: int = Field(default=60, ge=10, le=500) + command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600) upload_max_mb: int = Field(default=100, ge=1, le=2048) allowed_attachment_extensions: List[str] = Field(default_factory=list) workspace_download_extensions: List[str] = Field(default_factory=list) diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py index 620b33a..38ed15b 100644 --- a/backend/services/platform_service.py +++ b/backend/services/platform_service.py @@ -13,6 +13,7 @@ from core.database import engine from core.settings import ( BOTS_WORKSPACE_ROOT, DEFAULT_CHAT_PULL_PAGE_SIZE, + DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, DEFAULT_PAGE_SIZE, DEFAULT_STT_AUDIO_FILTER, DEFAULT_STT_AUDIO_PREPROCESS, @@ -57,6 +58,7 @@ OPERATIONAL_ACTIVITY_EVENT_TYPES = { SETTING_KEYS = ( "page_size", "chat_pull_page_size", + "command_auto_unlock_seconds", "upload_max_mb", "allowed_attachment_extensions", "workspace_download_extensions", @@ -91,6 +93,15 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "is_public": True, "sort_order": 8, }, + "command_auto_unlock_seconds": { + "name": "发送按钮自动恢复秒数", + "category": "chat", + "description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。", + "value_type": "integer", + "value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, + "is_public": True, + "sort_order": 9, + }, "upload_max_mb": { "name": "上传大小限制", "category": "upload", @@ -217,6 +228,12 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]: 10, 500, ), + "command_auto_unlock_seconds": _legacy_env_int( + "COMMAND_AUTO_UNLOCK_SECONDS", + DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, + 1, + 600, + ), "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), "allowed_attachment_extensions": _legacy_env_extensions( "ALLOWED_ATTACHMENT_EXTENSIONS", @@ -312,6 +329,7 @@ def default_platform_settings() -> PlatformSettingsPayload: return PlatformSettingsPayload( page_size=int(bootstrap["page_size"]), chat_pull_page_size=int(bootstrap["chat_pull_page_size"]), + command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]), upload_max_mb=int(bootstrap["upload_max_mb"]), allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), @@ -544,6 +562,10 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload: merged = defaults.model_dump() merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"]))) merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"]))) + merged["command_auto_unlock_seconds"] = max( + 1, + min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])) + ) merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"]) merged["allowed_attachment_extensions"] = _normalize_extension_list( data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]) @@ -567,6 +589,7 @@ def save_platform_settings(session: Session, payload: PlatformSettingsPayload) - normalized = PlatformSettingsPayload( page_size=max(1, min(100, int(payload.page_size))), chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))), + command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))), upload_max_mb=payload.upload_max_mb, allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions), workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b738680..fdcd863 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,8 @@ services: REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60} + COMMAND_AUTO_UNLOCK_SECONDS: ${COMMAND_AUTO_UNLOCK_SECONDS:-10} + DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} STT_ENABLED: ${STT_ENABLED:-true} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} diff --git a/frontend/src/App.css b/frontend/src/App.css index 505f958..bf97105 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -381,6 +381,76 @@ body { font-family: 'SF Mono', Menlo, Consolas, monospace; } +.md-lite-editor { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.md-lite-editor.is-full-height { + flex: 1 1 auto; + align-self: stretch; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.md-lite-editor.is-full-height .md-lite-toolbar { + flex: 0 0 auto; +} + +.md-lite-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 6px 8px; + border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); +} + +.md-lite-toolbtn { + border: 1px solid color-mix(in oklab, var(--line) 78%, var(--text) 8%); + background: var(--panel); + color: var(--text); + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + cursor: pointer; +} + +.md-lite-toolbtn:hover { + border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%); + color: var(--brand); +} + +.md-lite-toolbar-spacer { + flex: 1 1 auto; +} + +.md-lite-toolbar-hint { + font-size: 11px; + color: var(--muted); +} + +.md-lite-textarea { + min-height: 180px; +} + +.md-lite-editor.is-full-height .md-lite-textarea { + flex: 1 1 auto; + min-height: 0; + height: 100%; + max-height: none; + resize: none; +} + .field-label { color: var(--subtitle); font-size: 12px; @@ -1162,6 +1232,17 @@ body { color: #132a54; } +.app-shell[data-theme='light'] .md-lite-toolbar { + background: #f5f9ff; + border-color: #bccbe7; +} + +.app-shell[data-theme='light'] .md-lite-toolbtn { + background: #ffffff; + border-color: #c4d2eb; + color: #17305e; +} + .app-shell[data-theme='light'] .input:disabled, .app-shell[data-theme='light'] .select:disabled, .app-shell[data-theme='light'] .textarea:disabled { diff --git a/frontend/src/components/markdown/MarkdownLiteEditor.tsx b/frontend/src/components/markdown/MarkdownLiteEditor.tsx new file mode 100644 index 0000000..81f30d3 --- /dev/null +++ b/frontend/src/components/markdown/MarkdownLiteEditor.tsx @@ -0,0 +1,189 @@ +import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'; + +interface MarkdownLiteEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + className?: string; + textareaClassName?: string; + fullHeight?: boolean; + spellCheck?: boolean; + disabled?: boolean; + onSaveShortcut?: () => void; +} + +const INDENT = ' '; + +function joinClassNames(...values: Array) { + return values.filter(Boolean).join(' '); +} + +function clampSelection(textarea: HTMLTextAreaElement, start: number, end: number) { + window.requestAnimationFrame(() => { + textarea.focus(); + textarea.setSelectionRange(start, end); + }); +} + +function replaceSelection( + textarea: HTMLTextAreaElement, + onChange: (value: string) => void, + prefix: string, + suffix = '', + placeholder = '', +) { + const { value, selectionStart, selectionEnd } = textarea; + const selected = value.slice(selectionStart, selectionEnd); + const body = selected || placeholder; + const nextValue = `${value.slice(0, selectionStart)}${prefix}${body}${suffix}${value.slice(selectionEnd)}`; + onChange(nextValue); + const anchor = selectionStart + prefix.length; + const focus = anchor + body.length; + clampSelection(textarea, anchor, focus); +} + +function insertBlockSnippet( + textarea: HTMLTextAreaElement, + onChange: (value: string) => void, + snippet: string, + selectionOffsetStart = 0, + selectionOffsetEnd = snippet.length, +) { + const { value, selectionStart, selectionEnd } = textarea; + const needsLeadingBreak = selectionStart > 0 && value.charAt(selectionStart - 1) !== '\n'; + const needsTrailingBreak = selectionEnd < value.length && value.charAt(selectionEnd) !== '\n'; + const prefix = needsLeadingBreak ? '\n' : ''; + const suffix = needsTrailingBreak ? '\n' : ''; + const insertion = `${prefix}${snippet}${suffix}`; + const nextValue = `${value.slice(0, selectionStart)}${insertion}${value.slice(selectionEnd)}`; + onChange(nextValue); + const anchor = selectionStart + prefix.length + selectionOffsetStart; + const focus = selectionStart + prefix.length + selectionOffsetEnd; + clampSelection(textarea, anchor, focus); +} + +function replaceLineBlock( + textarea: HTMLTextAreaElement, + onChange: (value: string) => void, + transform: (line: string, index: number) => string, +) { + const { value, selectionStart, selectionEnd } = textarea; + const lineStart = value.lastIndexOf('\n', Math.max(0, selectionStart - 1)) + 1; + const nextBreak = value.indexOf('\n', selectionEnd); + const lineEnd = nextBreak === -1 ? value.length : nextBreak; + const block = value.slice(lineStart, lineEnd); + const nextBlock = block.split('\n').map(transform).join('\n'); + const nextValue = `${value.slice(0, lineStart)}${nextBlock}${value.slice(lineEnd)}`; + onChange(nextValue); + clampSelection(textarea, lineStart, lineStart + nextBlock.length); +} + +function indentSelection(textarea: HTMLTextAreaElement, onChange: (value: string) => void, reverse = false) { + const { value, selectionStart, selectionEnd } = textarea; + const lineStart = value.lastIndexOf('\n', Math.max(0, selectionStart - 1)) + 1; + const nextBreak = value.indexOf('\n', selectionEnd); + const lineEnd = nextBreak === -1 ? value.length : nextBreak; + const block = value.slice(lineStart, lineEnd); + const lines = block.split('\n'); + const nextLines = lines.map((line) => { + if (!reverse) return `${INDENT}${line}`; + if (line.startsWith(INDENT)) return line.slice(INDENT.length); + if (line.startsWith('\t')) return line.slice(1); + return line; + }); + const nextBlock = nextLines.join('\n'); + const nextValue = `${value.slice(0, lineStart)}${nextBlock}${value.slice(lineEnd)}`; + onChange(nextValue); + clampSelection(textarea, lineStart, lineStart + nextBlock.length); +} + +export function MarkdownLiteEditor({ + value, + onChange, + placeholder, + rows = 12, + className, + textareaClassName, + fullHeight = false, + spellCheck = false, + disabled = false, + onSaveShortcut, +}: MarkdownLiteEditorProps) { + const textareaRef = useRef(null); + const tableSnippet = '| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| Value 1 | Value 2 | Value 3 |'; + const tableHeaderStart = 2; + const tableHeaderEnd = '| Column 1 | Column 2 | Column 3 |'.length - 2; + + const actions = useMemo( + () => [ + { key: 'h1', label: 'H1', title: 'Heading 1', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `# ${line}`) }, + { key: 'h2', label: 'H2', title: 'Heading 2', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `## ${line}`) }, + { key: 'bold', label: 'B', title: 'Bold', run: () => replaceSelection(textareaRef.current!, onChange, '**', '**', 'bold text') }, + { key: 'italic', label: 'I', title: 'Italic', run: () => replaceSelection(textareaRef.current!, onChange, '*', '*', 'italic text') }, + { key: 'ul', label: '- List', title: 'Bullet List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `- ${line}`) }, + { key: 'ol', label: '1. List', title: 'Ordered List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line, index) => `${index + 1}. ${line}`) }, + { key: 'quote', label: '> Quote', title: 'Quote', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `> ${line}`) }, + { key: 'code', label: '', title: 'Code Block', run: () => replaceSelection(textareaRef.current!, onChange, '```\n', '\n```', 'code') }, + { key: 'link', label: 'Link', title: 'Link', run: () => replaceSelection(textareaRef.current!, onChange, '[', '](https://)', 'link text') }, + { + key: 'table', + label: 'Table', + title: 'Insert Table', + run: () => insertBlockSnippet(textareaRef.current!, onChange, tableSnippet, tableHeaderStart, tableHeaderEnd), + }, + ], + [onChange, tableHeaderEnd, tableHeaderStart, tableSnippet], + ); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + const textarea = textareaRef.current; + if (!textarea || disabled) return; + + if (event.key === 'Tab') { + event.preventDefault(); + indentSelection(textarea, onChange, event.shiftKey); + return; + } + + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's' && onSaveShortcut) { + event.preventDefault(); + onSaveShortcut(); + } + }; + + return ( +
+
+ {actions.map((action) => ( + + ))} + + Tab +
+