测试块编辑器笔记

This commit is contained in:
DelLevin-Home
2026-06-26 05:53:40 +08:00
parent 22797603f5
commit f7ef50a677
20 changed files with 3743 additions and 1 deletions

View File

@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import '../../models/note_plus_models.dart';
/// AppFlowy 风格的格式化工具栏
///
/// 样式参考 AppFlowy tool_bar.dart:
/// - 高度 36pxiconSize 18 * 2
/// - 背景 #f2f2f2
/// - 按钮 18px 图标,~32px 宽
/// - 切换态:#00bcf0 背景 + 白色图标
class BlockToolbar extends StatelessWidget {
final NoteBlockType currentBlockType;
final Set<InlineFormatType> activeFormats;
final void Function(InlineFormatType) onFormatToggle;
final void Function(NoteBlockType) onBlockTypeChange;
final VoidCallback onUndo;
final VoidCallback onRedo;
const BlockToolbar({
super.key,
required this.currentBlockType,
required this.activeFormats,
required this.onFormatToggle,
required this.onBlockTypeChange,
required this.onUndo,
required this.onRedo,
});
static const double _iconSize = 18;
static const double _buttonWidth = 32;
static const Color _bgColor = Color(0xFFF2F2F2);
static const Color _toggledColor = Color(0xFF00BCF0);
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: const BoxDecoration(color: _bgColor),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
// 撤销/重做
_iconButton(Icons.undo_outlined, onUndo, false),
_iconButton(Icons.redo_outlined, onRedo, false),
_divider(),
// 内联格式
_iconButton(Icons.format_bold, () => onFormatToggle(InlineFormatType.bold),
activeFormats.contains(InlineFormatType.bold)),
_iconButton(Icons.format_italic, () => onFormatToggle(InlineFormatType.italic),
activeFormats.contains(InlineFormatType.italic)),
_iconButton(Icons.format_underlined, () => onFormatToggle(InlineFormatType.underline),
activeFormats.contains(InlineFormatType.underline)),
_iconButton(Icons.format_strikethrough, () => onFormatToggle(InlineFormatType.strikethrough),
activeFormats.contains(InlineFormatType.strikethrough)),
_divider(),
// 标题
_headingButton('H1', NoteBlockType.heading1),
_headingButton('H2', NoteBlockType.heading2),
_headingButton('H3', NoteBlockType.heading3),
_divider(),
// 列表
_iconButton(Icons.format_list_numbered, () => onBlockTypeChange(NoteBlockType.numberedList),
currentBlockType == NoteBlockType.numberedList),
_iconButton(Icons.format_list_bulleted, () => onBlockTypeChange(NoteBlockType.bulletList),
currentBlockType == NoteBlockType.bulletList),
_iconButton(Icons.check_box_outlined, () => onBlockTypeChange(NoteBlockType.checklist),
currentBlockType == NoteBlockType.checklist),
_divider(),
// 代码/引用
_iconButton(Icons.code, () => onFormatToggle(InlineFormatType.inlineCode),
activeFormats.contains(InlineFormatType.inlineCode)),
_iconButton(Icons.format_quote, () => onBlockTypeChange(NoteBlockType.quote),
currentBlockType == NoteBlockType.quote),
// 块类型选择
_divider(),
_blockTypeSelector(context, colors),
],
),
);
}
Widget _iconButton(IconData icon, VoidCallback onTap, bool isToggled) {
return SizedBox(
width: _buttonWidth,
child: Material(
color: isToggled ? _toggledColor : Colors.transparent,
borderRadius: BorderRadius.circular(2),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(2),
hoverColor: isToggled ? _toggledColor : const Color(0xFFE0E0E0),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Icon(icon,
size: _iconSize,
color: isToggled ? Colors.white : const Color(0xFF333333)),
),
),
),
);
}
Widget _headingButton(String label, NoteBlockType type) {
final isToggled = currentBlockType == type;
return SizedBox(
width: _buttonWidth,
child: Material(
color: isToggled ? _toggledColor : Colors.transparent,
borderRadius: BorderRadius.circular(2),
child: InkWell(
onTap: () => onBlockTypeChange(type),
borderRadius: BorderRadius.circular(2),
hoverColor: isToggled ? _toggledColor : const Color(0xFFE0E0E0),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: Center(
child: Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isToggled ? Colors.white : const Color(0xFF333333),
)),
),
),
),
);
}
Widget _divider() {
return Container(
width: 1,
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
color: const Color(0xFFE0E0E0),
);
}
Widget _blockTypeSelector(BuildContext context, ColorScheme colors) {
return GestureDetector(
onTap: () => _showPicker(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(currentBlockType.label,
style: const TextStyle(fontSize: 12, color: Color(0xFF4F4F4F))),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 16, color: Color(0xFF828282)),
],
),
),
);
}
void _showPicker(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36, height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 16),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
...NoteBlockType.values.map((type) {
final isSelected = type == currentBlockType;
return ListTile(
dense: true,
leading: Icon(_getBlockIcon(type), size: 20,
color: isSelected ? _toggledColor : colors.onSurface.withValues(alpha: 0.5)),
title: Text(type.label,
style: TextStyle(fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? _toggledColor : colors.onSurface)),
trailing: isSelected
? const Icon(Icons.check, size: 18, color: _toggledColor)
: null,
onTap: () {
Navigator.pop(ctx);
onBlockTypeChange(type);
},
);
}),
const SizedBox(height: 8),
],
),
);
},
);
}
IconData _getBlockIcon(NoteBlockType type) {
switch (type) {
case NoteBlockType.paragraph:
return Icons.notes;
case NoteBlockType.heading1:
case NoteBlockType.heading2:
case NoteBlockType.heading3:
return Icons.title;
case NoteBlockType.bulletList:
return Icons.format_list_bulleted;
case NoteBlockType.numberedList:
return Icons.format_list_numbered;
case NoteBlockType.checklist:
return Icons.check_box_outlined;
case NoteBlockType.quote:
return Icons.format_quote;
case NoteBlockType.codeBlock:
return Icons.code;
case NoteBlockType.divider:
return Icons.horizontal_rule;
}
}
}

View File

@@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import '../../models/note_plus_models.dart';
/// 单个块的渲染组件
///
/// 负责根据 block type 渲染不同 UI。
/// 焦点 block 使用 TextEditingController 编辑,非焦点 block 渲染为静态 Text。
class NotePlusBlockWidget extends StatelessWidget {
final NoteBlock block;
final int index;
final int numberedIndex; // 有序列表序号
final bool isFocused;
final TextEditingController? controller;
final FocusNode? focusNode;
final VoidCallback onTap;
final VoidCallback? onToggleChecklist;
final void Function(String)? onTextChanged;
final void Function(TextSelection)? onSelectionChanged;
const NotePlusBlockWidget({
super.key,
required this.block,
required this.index,
this.numberedIndex = 1,
required this.isFocused,
this.controller,
this.focusNode,
required this.onTap,
this.onToggleChecklist,
this.onTextChanged,
this.onSelectionChanged,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (block.type == NoteBlockType.divider) {
return _buildDivider(colors);
}
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧标记区
_buildPrefix(colors),
const SizedBox(width: 4),
// 内容区
Expanded(child: _buildContent(context, colors)),
],
),
);
}
/// 块类型前缀标记
Widget _buildPrefix(ColorScheme colors) {
switch (block.type) {
case NoteBlockType.bulletList:
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
),
);
case NoteBlockType.numberedList:
return Padding(
padding: const EdgeInsets.only(top: 10),
child: SizedBox(
width: 24,
child: Text(
'$numberedIndex.',
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
),
);
case NoteBlockType.checklist:
return Padding(
padding: const EdgeInsets.only(top: 6),
child: GestureDetector(
onTap: onToggleChecklist,
child: Icon(
block.metadata['checked'] == true
? Icons.check_box
: Icons.check_box_outline_blank,
size: 20,
color: block.metadata['checked'] == true
? colors.primary
: colors.onSurface.withValues(alpha: 0.4),
),
),
);
case NoteBlockType.quote:
// AppFlowy: 4px wide grey.shade300 left border
return Container(
width: 4,
margin: const EdgeInsets.only(top: 6, bottom: 6, right: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
);
default:
return const SizedBox(width: 0);
}
}
/// 块内容
Widget _buildContent(BuildContext context, ColorScheme colors) {
final style = _getTextStyle(colors);
Widget content;
if (!isFocused) {
content = Padding(
padding: _getContentPadding(),
child: block.text.isEmpty
? Text(_getPlaceholder(), style: style.copyWith(
color: colors.onSurface.withValues(alpha: 0.25)))
: _buildRichText(block.text, style, colors),
);
} else {
content = _buildEditable(style, colors);
}
// AppFlowy code block: grey.shade50 background, 2px border radius
if (block.type == NoteBlockType.codeBlock) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2),
),
child: content,
);
}
return content;
}
Widget _buildEditable(TextStyle style, ColorScheme colors) {
return TextField(
controller: controller,
focusNode: focusNode,
style: style,
maxLines: null,
minLines: 1,
decoration: InputDecoration(
hintText: _getPlaceholder(),
hintStyle: style.copyWith(
color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none,
contentPadding: _getContentPadding(),
isDense: true,
),
onChanged: onTextChanged,
// selection handler 通过 controller listener 处理
);
}
Widget _buildRichText(String text, TextStyle style, ColorScheme colors) {
if (block.formatting.isEmpty) {
return Text(text, style: style);
}
final spans = _buildTextSpans(text, style, colors);
return Text.rich(
TextSpan(children: spans),
maxLines: null,
);
}
List<InlineSpan> _buildTextSpans(
String text, TextStyle baseStyle, ColorScheme colors) {
// 收集所有格式边界
final events = <_FormatEvent>[];
for (final span in block.formatting) {
if (span.start < text.length) {
events.add(_FormatEvent(span.start, true, span.formats));
events.add(_FormatEvent(
span.end > text.length ? text.length : span.end, false, span.formats));
}
}
events.sort((a, b) {
if (a.pos != b.pos) return a.pos.compareTo(b.pos);
// 关闭优先于打开
if (a.isStart && !b.isStart) return 1;
if (!a.isStart && b.isStart) return -1;
return 0;
});
if (events.isEmpty) return [TextSpan(text: text, style: baseStyle)];
final spans = <InlineSpan>[];
int lastPos = 0;
final activeFormats = <InlineFormatType>{};
for (final event in events) {
if (event.pos > lastPos) {
spans.add(TextSpan(
text: text.substring(lastPos, event.pos),
style: _applyFormats(baseStyle, activeFormats, colors),
));
}
if (event.isStart) {
activeFormats.addAll(event.formats);
} else {
activeFormats.removeAll(event.formats);
}
lastPos = event.pos;
}
if (lastPos < text.length) {
spans.add(TextSpan(
text: text.substring(lastPos),
style: _applyFormats(baseStyle, activeFormats, colors),
));
}
return spans;
}
TextStyle _applyFormats(
TextStyle style, Set<InlineFormatType> formats, ColorScheme colors) {
if (formats.isEmpty) return style;
return style.copyWith(
fontWeight: formats.contains(InlineFormatType.bold)
? FontWeight.bold
: null,
fontStyle: formats.contains(InlineFormatType.italic)
? FontStyle.italic
: null,
decoration: _getTextDecoration(formats),
fontFamily: formats.contains(InlineFormatType.inlineCode)
? 'monospace'
: null,
fontSize: formats.contains(InlineFormatType.inlineCode) ? 13 : null,
color: formats.contains(InlineFormatType.inlineCode)
? Colors.blue.shade900.withValues(alpha: 0.9)
: null,
backgroundColor: formats.contains(InlineFormatType.inlineCode)
? Colors.grey.shade50
: null,
);
}
TextDecoration? _getTextDecoration(Set<InlineFormatType> formats) {
final decorations = <TextDecoration>[];
if (formats.contains(InlineFormatType.underline)) {
decorations.add(TextDecoration.underline);
}
if (formats.contains(InlineFormatType.strikethrough)) {
decorations.add(TextDecoration.lineThrough);
}
if (decorations.isEmpty) return null;
return TextDecoration.combine(decorations);
}
TextStyle _getTextStyle(ColorScheme colors) {
// AppFlowy: 18px, w300, height 1.3, letter-spacing 0.6
final base = TextStyle(
color: colors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w300,
height: 1.3,
letterSpacing: 0.6,
);
switch (block.type) {
case NoteBlockType.heading1:
// AppFlowy H1: 34px, w300, height 1.15, 70% opacity
return base.copyWith(
fontSize: 34, fontWeight: FontWeight.w300, height: 1.15,
letterSpacing: 0,
color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.heading2:
// AppFlowy H2: 24px, w400, height 1.15, 70% opacity
return base.copyWith(
fontSize: 24, fontWeight: FontWeight.w400, height: 1.15,
letterSpacing: 0,
color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.heading3:
// AppFlowy H3: 20px, w500, height 1.25, 70% opacity
return base.copyWith(
fontSize: 20, fontWeight: FontWeight.w500, height: 1.25,
letterSpacing: 0,
color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.quote:
// AppFlowy quote: 60% opacity text
return base.copyWith(
color: colors.onSurface.withValues(alpha: 0.6));
case NoteBlockType.codeBlock:
// AppFlowy code: 13px, blue.shade900 at 90%, height 1.15, monospace
return base.copyWith(
fontSize: 13,
fontWeight: FontWeight.w400,
fontFamily: 'monospace',
height: 1.15,
letterSpacing: 0,
color: Colors.blue.shade900.withValues(alpha: 0.9),
);
case NoteBlockType.checklist:
return base.copyWith(
decoration: block.metadata['checked'] == true
? TextDecoration.lineThrough
: null,
color: block.metadata['checked'] == true
? colors.onSurface.withValues(alpha: 0.4)
: null,
);
default:
return base;
}
}
EdgeInsets _getContentPadding() {
switch (block.type) {
case NoteBlockType.heading1:
return const EdgeInsets.only(top: 16);
case NoteBlockType.heading2:
return const EdgeInsets.only(top: 8);
case NoteBlockType.heading3:
return const EdgeInsets.only(top: 8);
case NoteBlockType.codeBlock:
// AppFlowy code: grey.shade50 background, no inner padding
return const EdgeInsets.symmetric(vertical: 8, horizontal: 12);
case NoteBlockType.quote:
// AppFlowy quote: 6px top, 2px bottom inner padding
return const EdgeInsets.only(top: 6, bottom: 2);
default:
// AppFlowy paragraph: 10px top spacing
return const EdgeInsets.only(top: 10);
}
}
String _getPlaceholder() {
switch (block.type) {
case NoteBlockType.heading1:
return '标题1';
case NoteBlockType.heading2:
return '标题2';
case NoteBlockType.heading3:
return '标题3';
case NoteBlockType.bulletList:
return '列表项';
case NoteBlockType.numberedList:
return '列表项';
case NoteBlockType.checklist:
return '待办事项';
case NoteBlockType.quote:
return '引用';
case NoteBlockType.codeBlock:
return '代码';
default:
return '输入文字,或输入 / 打开菜单';
}
}
Widget _buildDivider(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: GestureDetector(
onTap: onTap,
child: Container(
height: 1,
color: colors.outlineVariant,
),
),
);
}
}
class _FormatEvent {
final int pos;
final bool isStart;
final Set<InlineFormatType> formats;
_FormatEvent(this.pos, this.isStart, this.formats);
}

View File

@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../models/note_plus_models.dart';
import '../../providers/note_plus_provider.dart';
import 'note_plus_block_widget.dart';
import 'slash_command_menu.dart';
/// Note Plus 块编辑器
///
/// 管理 block 列表、焦点切换、键盘事件处理。
/// 使用 ReorderableListView 支持拖拽排序。
class NotePlusEditor extends StatefulWidget {
const NotePlusEditor({super.key});
@override
State<NotePlusEditor> createState() => _NotePlusEditorState();
}
class _NotePlusEditorState extends State<NotePlusEditor> {
final ScrollController _scrollController = ScrollController();
final Map<String, TextEditingController> _controllers = {};
final Map<String, FocusNode> _focusNodes = {};
OverlayEntry? _slashMenuOverlay;
@override
void dispose() {
_scrollController.dispose();
for (final c in _controllers.values) {
c.dispose();
}
for (final fn in _focusNodes.values) {
fn.dispose();
}
_removeSlashMenu();
super.dispose();
}
TextEditingController _getController(NoteBlock block) {
return _controllers.putIfAbsent(block.id, () {
final c = TextEditingController(text: block.text);
c.addListener(() => _onControllerChanged(block));
return c;
});
}
FocusNode _getFocusNode(NoteBlock block, int index) {
return _focusNodes.putIfAbsent(block.id, () {
final fn = FocusNode(onKeyEvent: (node, event) => _handleKeyEvent(index, event));
fn.addListener(() {
if (fn.hasFocus) {
context.read<NotePlusProvider>().setFocusedBlock(index);
}
});
return fn;
});
}
void _onControllerChanged(NoteBlock block) {
final provider = context.read<NotePlusProvider>();
final idx = provider.blocks.indexWhere((b) => b.id == block.id);
if (idx < 0) return;
final text = _controllers[block.id]!.text;
// 更新 block 文本(静默,不 rebuild
provider.updateBlockSilent(
idx,
provider.blocks[idx].copyWith(text: text),
);
// 检测斜杠命令
if (text == '/' || (text.startsWith('/') && text.length > 1)) {
_showSlashMenu(idx, text);
} else {
_removeSlashMenu();
}
}
void _onBlockTap(int index) {
final provider = context.read<NotePlusProvider>();
provider.setFocusedBlock(index);
final block = provider.blocks[index];
if (block.type == NoteBlockType.divider) return;
final node = _getFocusNode(block, index);
if (!node.hasFocus) {
node.requestFocus();
}
}
void _onToggleChecklist(int index) {
context.read<NotePlusProvider>().toggleChecklist(index);
}
/// 处理键盘事件
KeyEventResult _handleKeyEvent(int index, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final provider = context.read<NotePlusProvider>();
final block = provider.blocks[index];
final controller = _controllers[block.id];
if (controller == null) return KeyEventResult.ignored;
final sel = controller.selection;
// Enter → 分割 block
if (event.logicalKey == LogicalKeyboardKey.enter &&
!HardwareKeyboard.instance.isShiftPressed) {
final pos = sel.isValid ? sel.baseOffset : controller.text.length;
provider.splitBlock(index, pos);
// 聚焦新 block
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusBlockAtIndex(index + 1, atStart: true);
});
return KeyEventResult.handled;
}
// Backspace 在位置0 → 合并到前一个
if (event.logicalKey == LogicalKeyboardKey.backspace &&
sel.isValid &&
sel.isCollapsed &&
sel.baseOffset == 0 &&
index > 0) {
final cursorPos = provider.mergeWithPrevious(index);
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusBlockAtIndex(index - 1, atCursor: cursorPos);
});
return KeyEventResult.handled;
}
// ↑ 在位置0 → 聚焦上一个
if (event.logicalKey == LogicalKeyboardKey.arrowUp &&
sel.isValid &&
sel.isCollapsed &&
sel.baseOffset == 0 &&
index > 0) {
_focusBlockAtIndex(index - 1, atEnd: true);
return KeyEventResult.handled;
}
// ↓ 在末位 → 聚焦下一个
if (event.logicalKey == LogicalKeyboardKey.arrowDown &&
sel.isValid &&
sel.isCollapsed &&
sel.baseOffset == controller.text.length &&
index < provider.blocks.length - 1) {
_focusBlockAtIndex(index + 1, atStart: true);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _focusBlockAtIndex(int index, {bool atStart = false, bool atEnd = false, int? atCursor}) {
final provider = context.read<NotePlusProvider>();
if (index < 0 || index >= provider.blocks.length) return;
final block = provider.blocks[index];
if (block.type == NoteBlockType.divider) return;
provider.setFocusedBlock(index);
final node = _getFocusNode(block, index);
final controller = _getController(block);
// 等 controller 同步后设置光标
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!node.hasFocus) node.requestFocus();
int cursorPos;
if (atCursor != null) {
cursorPos = atCursor;
} else if (atEnd) {
cursorPos = controller.text.length;
} else {
cursorPos = 0;
}
controller.selection = TextSelection.collapsed(offset: cursorPos);
});
}
// ========== Slash Menu ==========
void _showSlashMenu(int blockIndex, String text) {
_removeSlashMenu();
final query = text.length > 1 ? text.substring(1) : '';
_slashMenuOverlay = OverlayEntry(
builder: (context) => Positioned(
bottom: MediaQuery.of(context).viewInsets.bottom + 60,
left: 16,
right: 16,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
child: SlashCommandMenu(
query: query,
onSelect: (type) {
_removeSlashMenu();
final provider = context.read<NotePlusProvider>();
// 清除 / 文本
provider.updateBlockSilent(
blockIndex,
provider.blocks[blockIndex].copyWith(text: ''),
);
_controllers[provider.blocks[blockIndex].id]?.text = '';
// 转换类型
provider.convertBlockType(blockIndex, type);
},
onDismiss: _removeSlashMenu,
),
),
),
);
Overlay.of(context).insert(_slashMenuOverlay!);
}
void _removeSlashMenu() {
_slashMenuOverlay?.remove();
_slashMenuOverlay = null;
}
// ========== Build ==========
@override
Widget build(BuildContext context) {
return Consumer<NotePlusProvider>(
builder: (context, provider, _) {
final blocks = provider.blocks;
return ListView.builder(
controller: _scrollController,
itemCount: blocks.length,
itemBuilder: (context, index) {
final block = blocks[index];
final isFocused = provider.focusedBlockIndex == index;
// 计算有序列表序号
int numberedIndex = 1;
if (block.type == NoteBlockType.numberedList) {
for (int i = index - 1; i >= 0; i--) {
if (blocks[i].type == NoteBlockType.numberedList) {
numberedIndex++;
} else {
break;
}
}
}
// 同步 controller 文本
final controller = isFocused ? _getController(block) : null;
if (controller != null && controller.text != block.text) {
// 用 addPostFrameCallback 避免 build 期间修改
WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.text != block.text) {
controller.text = block.text;
}
});
}
return Padding(
key: ValueKey(block.id),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Block 内容
Expanded(
child: NotePlusBlockWidget(
block: block,
index: index,
numberedIndex: numberedIndex,
isFocused: isFocused,
controller: controller,
focusNode: isFocused ? _getFocusNode(block, index) : null,
onTap: () => _onBlockTap(index),
onToggleChecklist: block.type == NoteBlockType.checklist
? () => _onToggleChecklist(index)
: null,
),
),
],
),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../../models/note_plus_models.dart';
/// 斜杠命令菜单
///
/// 输入 `/` 后弹出,显示可插入的 block 类型列表。
/// 支持按中文关键词过滤。
class SlashCommandMenu extends StatefulWidget {
final String query;
final void Function(NoteBlockType) onSelect;
final VoidCallback onDismiss;
const SlashCommandMenu({
super.key,
required this.query,
required this.onSelect,
required this.onDismiss,
});
@override
State<SlashCommandMenu> createState() => _SlashCommandMenuState();
}
class _SlashCommandMenuState extends State<SlashCommandMenu> {
int _selectedIndex = 0;
static const _menuItems = [
_MenuItem(NoteBlockType.paragraph, Icons.notes, '正文', '段落'),
_MenuItem(NoteBlockType.heading1, Icons.title, '标题1', '大标题'),
_MenuItem(NoteBlockType.heading2, Icons.title, '标题2', '中标题'),
_MenuItem(NoteBlockType.heading3, Icons.title, '标题3', '小标题'),
_MenuItem(NoteBlockType.bulletList, Icons.format_list_bulleted, '无序列表', '圆点列表'),
_MenuItem(NoteBlockType.numberedList, Icons.format_list_numbered, '有序列表', '数字列表'),
_MenuItem(NoteBlockType.checklist, Icons.check_box_outlined, '待办', '清单'),
_MenuItem(NoteBlockType.quote, Icons.format_quote, '引用', '引述'),
_MenuItem(NoteBlockType.codeBlock, Icons.code, '代码块', '源码'),
_MenuItem(NoteBlockType.divider, Icons.horizontal_rule, '分割线', '分隔'),
];
List<_MenuItem> get _filtered {
if (widget.query.isEmpty) return _menuItems;
final q = widget.query.toLowerCase();
return _menuItems.where((item) {
return item.label.toLowerCase().contains(q) ||
item.keywords.toLowerCase().contains(q);
}).toList();
}
@override
void didUpdateWidget(SlashCommandMenu oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
final items = _filtered;
if (_selectedIndex >= items.length) {
_selectedIndex = items.isEmpty ? 0 : items.length - 1;
}
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final items = _filtered;
if (items.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Text('无匹配项',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final isSelected = index == _selectedIndex;
return InkWell(
onTap: () => widget.onSelect(item.type),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: isSelected
? colors.primary.withValues(alpha: 0.08)
: null,
child: Row(
children: [
Icon(item.icon, size: 20,
color: isSelected
? colors.primary
: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Expanded(
child: Text(item.label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
color: isSelected ? colors.primary : colors.onSurface,
)),
),
Text(item.keywords,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.3),
)),
],
),
),
);
},
),
);
}
}
class _MenuItem {
final NoteBlockType type;
final IconData icon;
final String label;
final String keywords;
const _MenuItem(this.type, this.icon, this.label, this.keywords);
}