generated from dellevin/template
优化打包体积
This commit is contained in:
@@ -1,230 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/note_plus_models.dart';
|
||||
|
||||
/// AppFlowy 风格的格式化工具栏
|
||||
///
|
||||
/// 样式参考 AppFlowy tool_bar.dart:
|
||||
/// - 高度 36px(iconSize 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// 清理已删除 block 对应的控制器和焦点节点
|
||||
void _cleanupStaleEntries(List<NoteBlock> currentBlocks) {
|
||||
final currentIds = currentBlocks.map((b) => b.id).toSet();
|
||||
final staleIds = _controllers.keys.where((id) => !currentIds.contains(id)).toList();
|
||||
for (final id in staleIds) {
|
||||
_controllers.remove(id)?.dispose();
|
||||
}
|
||||
final staleFocusIds = _focusNodes.keys.where((id) => !currentIds.contains(id)).toList();
|
||||
for (final id in staleFocusIds) {
|
||||
_focusNodes.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 清理已删除 block 对应的控制器和焦点节点,防止内存泄漏
|
||||
_cleanupStaleEntries(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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/note_plus_models.dart';
|
||||
|
||||
/// AppFlowy 风格的文件树列表项
|
||||
///
|
||||
/// 匹配 AppFlowy ViewSectionItem 的样式:
|
||||
/// - 固定高度 26px
|
||||
/// - 16px 图标 + 文字
|
||||
/// - 12px 字号
|
||||
/// - hover 时显示操作按钮
|
||||
class NotePlusTreeItem extends StatefulWidget {
|
||||
final NotePlusDocument doc;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const NotePlusTreeItem({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.onRename,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NotePlusTreeItem> createState() => _NotePlusTreeItemState();
|
||||
}
|
||||
|
||||
class _NotePlusTreeItemState extends State<NotePlusTreeItem> {
|
||||
bool _isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final isSelected = widget.isSelected;
|
||||
final showActions = _isHovering || isSelected;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.only(left: 22, right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colors.primary.withValues(alpha: 0.08)
|
||||
: _isHovering
|
||||
? colors.onSurface.withValues(alpha: 0.04)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 文档图标
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: Icon(
|
||||
_getBlockIcon(widget.doc),
|
||||
size: 14,
|
||||
color: isSelected
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
// 文档标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.doc.title.isEmpty ? '无标题' : widget.doc.title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.75),
|
||||
),
|
||||
overflow: TextOverflow.clip,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
// hover 操作按钮
|
||||
if (showActions)
|
||||
_buildActions(colors),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions(ColorScheme colors) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.onRename != null)
|
||||
_ActionButton(
|
||||
icon: Icons.edit_outlined,
|
||||
onTap: widget.onRename!,
|
||||
colors: colors,
|
||||
),
|
||||
if (widget.onDelete != null)
|
||||
_ActionButton(
|
||||
icon: Icons.delete_outline,
|
||||
onTap: widget.onDelete!,
|
||||
colors: colors,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getBlockIcon(NotePlusDocument doc) {
|
||||
// 根据第一个块的类型显示图标
|
||||
if (doc.blocks.isEmpty) return Icons.description_outlined;
|
||||
final firstType = doc.blocks.first.type;
|
||||
switch (firstType) {
|
||||
case NoteBlockType.heading1:
|
||||
case NoteBlockType.heading2:
|
||||
case NoteBlockType.heading3:
|
||||
return Icons.title;
|
||||
case NoteBlockType.checklist:
|
||||
return Icons.check_box_outlined;
|
||||
case NoteBlockType.codeBlock:
|
||||
return Icons.code;
|
||||
case NoteBlockType.quote:
|
||||
return Icons.format_quote;
|
||||
default:
|
||||
return Icons.description_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme colors;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.colors,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Icon(icon, size: 14,
|
||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user