generated from dellevin/template
386 lines
12 KiB
Dart
386 lines
12 KiB
Dart
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);
|
|
}
|