测试块编辑器笔记

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

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:url_launcher/url_launcher.dart';
import 'pages/home_page.dart';
import 'utils/theme/app_theme.dart';
@@ -13,6 +14,7 @@ import 'utils/changelog_service.dart';
import 'utils/sync/auto_backup_service.dart';
import 'utils/usage_stats_service.dart';
import 'providers/app_provider.dart';
import 'providers/note_plus_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -187,6 +189,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: widget.appProvider),
ChangeNotifierProvider(create: (_) => NotePlusProvider()),
],
child: Consumer<AppProvider>(
builder: (context, provider, _) {
@@ -200,6 +203,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
quill.FlutterQuillLocalizations.delegate,
],
supportedLocales: const [
Locale('zh', 'CN'),

View File

@@ -0,0 +1,311 @@
import 'dart:convert';
import 'package:uuid/uuid.dart';
/// 块类型枚举
enum NoteBlockType {
paragraph,
heading1,
heading2,
heading3,
bulletList,
numberedList,
checklist,
quote,
codeBlock,
divider;
String toJson() => name;
static NoteBlockType fromString(String value) {
return NoteBlockType.values.firstWhere(
(t) => t.name == value,
orElse: () => NoteBlockType.paragraph,
);
}
/// 中文标签
String get label {
switch (this) {
case NoteBlockType.paragraph:
return '正文';
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 '代码块';
case NoteBlockType.divider:
return '分割线';
}
}
}
/// 内联格式类型
enum InlineFormatType {
bold,
italic,
underline,
strikethrough,
inlineCode;
String toJson() => name;
static InlineFormatType fromString(String value) {
return InlineFormatType.values.firstWhere(
(t) => t.name == value,
orElse: () => InlineFormatType.bold,
);
}
}
/// 内联格式区间
class InlineFormatSpan {
final int start;
final int end;
final Set<InlineFormatType> formats;
const InlineFormatSpan({
required this.start,
required this.end,
required this.formats,
});
Map<String, dynamic> toJson() => {
's': start,
'e': end,
'f': formats.map((f) => f.toJson()).toList(),
};
factory InlineFormatSpan.fromJson(Map<String, dynamic> json) {
return InlineFormatSpan(
start: json['s'] as int? ?? 0,
end: json['e'] as int? ?? 0,
formats: (json['f'] as List<dynamic>?)
?.map((f) => InlineFormatType.fromString(f as String))
.toSet() ??
{},
);
}
InlineFormatSpan copyWith(
{int? start, int? end, Set<InlineFormatType>? formats}) {
return InlineFormatSpan(
start: start ?? this.start,
end: end ?? this.end,
formats: formats ?? Set.from(this.formats),
);
}
}
/// 单个内容块
class NoteBlock {
final String id;
final NoteBlockType type;
final String text;
final Map<String, dynamic> metadata;
final List<InlineFormatSpan> formatting;
NoteBlock({
String? id,
this.type = NoteBlockType.paragraph,
this.text = '',
Map<String, dynamic>? metadata,
List<InlineFormatSpan>? formatting,
}) : id = id ?? const Uuid().v4(),
metadata = metadata ?? {},
formatting = formatting ?? [];
Map<String, dynamic> toJson() => {
'id': id,
'type': type.toJson(),
'text': text,
'metadata': metadata,
'formatting': formatting.map((f) => f.toJson()).toList(),
};
factory NoteBlock.fromJson(Map<String, dynamic> json) {
return NoteBlock(
id: json['id'] as String?,
type: NoteBlockType.fromString(json['type'] as String? ?? 'paragraph'),
text: json['text'] as String? ?? '',
metadata: (json['metadata'] as Map<String, dynamic>?) ?? {},
formatting: (json['formatting'] as List<dynamic>?)
?.map(
(f) => InlineFormatSpan.fromJson(f as Map<String, dynamic>))
.toList() ??
[],
);
}
NoteBlock copyWith({
NoteBlockType? type,
String? text,
Map<String, dynamic>? metadata,
List<InlineFormatSpan>? formatting,
}) {
return NoteBlock(
id: id,
type: type ?? this.type,
text: text ?? this.text,
metadata: metadata ?? Map.from(this.metadata),
formatting: formatting ?? List.from(this.formatting),
);
}
/// 深拷贝
NoteBlock deepCopy() {
return NoteBlock(
id: id,
type: type,
text: text,
metadata: Map.from(metadata),
formatting: formatting
.map((f) => InlineFormatSpan(
start: f.start,
end: f.end,
formats: Set.from(f.formats),
))
.toList(),
);
}
}
/// Note Plus 文档
///
/// 仿 AppFlowy 的 View 模型:
/// - parentId 指向父文档 ID空字符串 = 根级文档)
/// - 每个文档既是页面也是容器,支持无限嵌套
class NotePlusDocument {
final String id;
final String title;
final String parentId; // 父文档 ID空字符串 = 根级
final int sortIndex; // 同级排序序号
final List<NoteBlock> blocks;
final String? blocksJson; // 原始 Delta JSONflutter_quill 模式优先使用)
final List<String> tags;
final List<String> images;
final DateTime createdAt;
final DateTime updatedAt;
final bool isDeleted;
NotePlusDocument({
String? id,
this.title = '',
this.parentId = '',
this.sortIndex = 0,
List<NoteBlock>? blocks,
this.blocksJson,
List<String>? tags,
List<String>? images,
DateTime? createdAt,
DateTime? updatedAt,
this.isDeleted = false,
}) : id = id ?? const Uuid().v4(),
blocks = blocks ?? [NoteBlock()],
tags = tags ?? [],
images = images ?? [],
createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
/// 从 JSONDB 行)创建
factory NotePlusDocument.fromJson(Map<String, dynamic> json) {
final blocksJson = json['blocks_json'] as String? ?? '[]';
final blocksList = (jsonDecode(blocksJson) as List<dynamic>)
.map((b) => NoteBlock.fromJson(b as Map<String, dynamic>))
.toList();
return NotePlusDocument(
id: json['id'] as String?,
title: json['title'] as String? ?? '',
parentId: json['parent_id'] as String? ?? '',
sortIndex: json['sort_index'] as int? ?? 0,
blocks: blocksList.isEmpty ? [NoteBlock()] : blocksList,
blocksJson: json['blocks_json'] as String?,
tags: _parseStringList(json['tags'] as String?),
images: _parseStringList(json['images'] as String?),
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String) ?? DateTime.now()
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.tryParse(json['updated_at'] as String) ?? DateTime.now()
: DateTime.now(),
isDeleted: (json['is_deleted'] as int?) == 1,
);
}
/// 序列化为 DB 行
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'parent_id': parentId,
'sort_index': sortIndex,
'blocks_json': blocksJson ?? jsonEncode(blocks.map((b) => b.toJson()).toList()),
'tags': tags.isEmpty ? null : jsonEncode(tags),
'images': images.isEmpty ? null : jsonEncode(images),
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
'is_deleted': isDeleted ? 1 : 0,
};
NotePlusDocument copyWith({
String? title,
String? parentId,
int? sortIndex,
List<NoteBlock>? blocks,
String? blocksJson,
List<String>? tags,
List<String>? images,
DateTime? updatedAt,
bool? isDeleted,
}) {
return NotePlusDocument(
id: id,
title: title ?? this.title,
parentId: parentId ?? this.parentId,
sortIndex: sortIndex ?? this.sortIndex,
blocks: blocks ?? List.from(this.blocks),
blocksJson: blocksJson ?? this.blocksJson,
tags: tags ?? List.from(this.tags),
images: images ?? List.from(this.images),
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// 深拷贝
NotePlusDocument deepCopy() {
return NotePlusDocument(
id: id,
title: title,
parentId: parentId,
sortIndex: sortIndex,
blocks: blocks.map((b) => b.deepCopy()).toList(),
tags: List.from(tags),
images: List.from(images),
createdAt: createdAt,
updatedAt: updatedAt,
isDeleted: isDeleted,
);
}
static List<String> _parseStringList(String? json) {
if (json == null || json.isEmpty) return [];
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((e) => e.toString()).toList();
} catch (_) {
return [];
}
}
}

View File

@@ -7,6 +7,7 @@ import '../utils/toast_util.dart';
import 'movies/movie_tab_page.dart';
import 'book/book_tab_page.dart';
import 'note/note_tab_page.dart';
import 'note_plus/note_plus_tab_page.dart';
import 'online_search/search_page.dart';
import 'online_search/online_search_page.dart';
import 'sync/webdav_sync_page.dart';
@@ -25,6 +26,7 @@ class _MainContentPageState extends State<MainContentPage> {
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
bool _showNotePlusTab = false;
late PageController _pageController;
bool _isTabTap = false;
@@ -48,6 +50,7 @@ class _MainContentPageState extends State<MainContentPage> {
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
_showNotePlusTab = _userPrefs.showNotePlusTab;
});
}
@@ -62,6 +65,7 @@ class _MainContentPageState extends State<MainContentPage> {
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
if (_showNotePlusTab) tabs.add(_TabItem('Plus', 3));
return tabs;
}
@@ -135,6 +139,7 @@ class _MainContentPageState extends State<MainContentPage> {
case 0: return '影视';
case 1: return '阅读';
case 2: return '笔记';
case 3: return 'Note Plus';
default: return 'MookNote';
}
}
@@ -368,6 +373,7 @@ class _MainContentPageState extends State<MainContentPage> {
if (_showMovieTab) const MovieTabPage(),
if (_showBookTab) const BookTabPage(),
if (_showNoteTab) const NoteTabPage(),
if (_showNotePlusTab) const NotePlusTabPage(),
],
);
},
@@ -379,6 +385,7 @@ class _MainContentPageState extends State<MainContentPage> {
case '影视': return Icons.movie_outlined;
case '阅读': return Icons.menu_book_outlined;
case '笔记': return Icons.note_outlined;
case 'Plus': return Icons.edit_note;
default: return Icons.circle;
}
}

View File

@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/note_plus_models.dart';
import '../../providers/note_plus_provider.dart';
/// Note Plus 只读查看页
class NotePlusDetailPage extends StatelessWidget {
final String documentId;
const NotePlusDetailPage({super.key, required this.documentId});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return FutureBuilder<NotePlusDocument?>(
future: context.read<NotePlusProvider>().getDeletedDocuments().then(
(_) => context.read<NotePlusProvider>().currentDocument?.id == documentId
? context.read<NotePlusProvider>().currentDocument
: null),
builder: (context, snapshot) {
// 直接从 provider 取
return Consumer<NotePlusProvider>(
builder: (context, provider, _) {
final doc = provider.currentDocument;
if (doc == null || doc.id != documentId) {
// 加载文档
provider.loadDocumentById(documentId);
return Scaffold(
backgroundColor: colors.surface,
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: Text(doc.title.isEmpty ? '无标题' : doc.title),
backgroundColor: colors.surface,
surfaceTintColor: Colors.transparent,
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
Navigator.pushNamed(context, '/note-plus-form',
arguments: doc.id);
},
),
],
),
body: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
itemCount: doc.blocks.length,
itemBuilder: (context, index) {
return _buildBlock(doc.blocks[index], index, doc.blocks, colors);
},
),
);
},
);
},
);
}
Widget _buildBlock(NoteBlock block, int index, List<NoteBlock> allBlocks,
ColorScheme colors) {
if (block.type == NoteBlockType.divider) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Container(height: 1, color: colors.outlineVariant),
);
}
final style = _getTextStyle(block, colors);
final text = block.text.isEmpty ? '(空)' : block.text;
return Padding(
padding: _getBlockPadding(block),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPrefix(block, index, allBlocks, colors),
Expanded(
child: block.type == NoteBlockType.codeBlock
? Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2),
),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: block.text.isEmpty
? Text('(空)', style: style.copyWith(
color: colors.onSurface.withValues(alpha: 0.2)))
: _buildRichText(text, style, block, colors),
)
: block.text.isEmpty
? Text(text, style: style.copyWith(
color: colors.onSurface.withValues(alpha: 0.2)))
: _buildRichText(text, style, block, colors),
),
],
),
);
}
Widget _buildPrefix(NoteBlock block, int index, List<NoteBlock> allBlocks,
ColorScheme colors) {
switch (block.type) {
case NoteBlockType.bulletList:
return Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Container(
width: 6, height: 6,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
),
);
case NoteBlockType.numberedList:
int num = 1;
for (int i = index - 1; i >= 0; i--) {
if (allBlocks[i].type == NoteBlockType.numberedList) {
num++;
} else {
break;
}
}
return Padding(
padding: const EdgeInsets.only(top: 6, right: 8),
child: Text('$num.', style: TextStyle(
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))),
);
case NoteBlockType.checklist:
return Padding(
padding: const EdgeInsets.only(top: 4, right: 8),
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:
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.shrink();
}
}
Widget _buildRichText(String text, TextStyle style, NoteBlock block,
ColorScheme colors) {
if (block.formatting.isEmpty) return Text(text, style: style);
final spans = <InlineSpan>[];
final events = <_FmtEvent>[];
for (final span in block.formatting) {
if (span.start < text.length) {
events.add(_FmtEvent(span.start, true, span.formats));
events.add(_FmtEvent(
span.end > text.length ? text.length : span.end, false, span.formats));
}
}
events.sort((a, b) => a.pos.compareTo(b.pos));
int lastPos = 0;
final active = <InlineFormatType>{};
for (final e in events) {
if (e.pos > lastPos) {
spans.add(TextSpan(
text: text.substring(lastPos, e.pos),
style: _applyFormats(style, active, colors),
));
}
if (e.isStart) {
active.addAll(e.formats);
} else {
active.removeAll(e.formats);
}
lastPos = e.pos;
}
if (lastPos < text.length) {
spans.add(TextSpan(
text: text.substring(lastPos),
style: _applyFormats(style, active, colors),
));
}
return Text.rich(TextSpan(children: spans));
}
TextStyle _applyFormats(TextStyle style, Set<InlineFormatType> formats,
ColorScheme colors) {
if (formats.isEmpty) return style;
return style.copyWith(
fontWeight: formats.contains(InlineFormatType.bold) ? FontWeight.w700 : null,
fontStyle: formats.contains(InlineFormatType.italic) ? FontStyle.italic : null,
decoration: _getDecoration(formats),
fontFamily: formats.contains(InlineFormatType.inlineCode) ? 'monospace' : null,
backgroundColor: formats.contains(InlineFormatType.inlineCode)
? colors.surfaceContainerHighest : null,
);
}
TextDecoration? _getDecoration(Set<InlineFormatType> formats) {
final list = <TextDecoration>[];
if (formats.contains(InlineFormatType.underline)) list.add(TextDecoration.underline);
if (formats.contains(InlineFormatType.strikethrough)) list.add(TextDecoration.lineThrough);
return list.isEmpty ? null : TextDecoration.combine(list);
}
TextStyle _getTextStyle(NoteBlock block, ColorScheme colors) {
// AppFlowy base: 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:
return base.copyWith(fontSize: 34, fontWeight: FontWeight.w300, height: 1.15,
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.heading2:
return base.copyWith(fontSize: 24, fontWeight: FontWeight.w400, height: 1.15,
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.heading3:
return base.copyWith(fontSize: 20, fontWeight: FontWeight.w500, height: 1.25,
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
case NoteBlockType.quote:
return base.copyWith(color: colors.onSurface.withValues(alpha: 0.6));
case NoteBlockType.codeBlock:
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 _getBlockPadding(NoteBlock block) {
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:
return const EdgeInsets.symmetric(vertical: 6);
case NoteBlockType.quote:
return const EdgeInsets.only(top: 6, bottom: 2);
default:
return const EdgeInsets.only(top: 10);
}
}
}
class _FmtEvent {
final int pos;
final bool isStart;
final Set<InlineFormatType> formats;
_FmtEvent(this.pos, this.isStart, this.formats);
}

View File

@@ -0,0 +1,448 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:provider/provider.dart';
import '../../providers/note_plus_provider.dart';
import '../../utils/toast_util.dart';
/// Note Plus 编辑页
class NotePlusFormPage extends StatefulWidget {
final String documentId;
const NotePlusFormPage({super.key, required this.documentId});
@override
State<NotePlusFormPage> createState() => _NotePlusFormPageState();
}
class _NotePlusFormPageState extends State<NotePlusFormPage> {
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
final _scrollController = ScrollController();
final _editorFocus = FocusNode();
quill.QuillController? _controller;
bool _isInitialized = false;
Timer? _autoSaveTimer;
int _charCount = 0;
@override
void dispose() {
_autoSaveTimer?.cancel();
_titleController.dispose();
_titleFocus.dispose();
_scrollController.dispose();
_editorFocus.dispose();
_controller?.dispose();
super.dispose();
}
// ─── 初始化 ─────────────────────────────────────
void _initDocument(NotePlusProvider provider) {
if (_isInitialized) return;
_isInitialized = true;
provider.loadDocumentById(widget.documentId).then((_) {
if (!mounted || provider.currentDocument == null) return;
_titleController.text = provider.currentDocument!.title;
quill.Document doc;
final raw = provider.currentDocument!.blocksJson;
if (raw != null && raw.isNotEmpty && raw.startsWith('[')) {
try {
final parsed = jsonDecode(raw) as List;
if (parsed.isNotEmpty &&
parsed.first is Map &&
(parsed.first as Map).containsKey('insert')) {
doc = quill.Document.fromJson(parsed);
} else {
doc = quill.Document();
}
} catch (_) {
doc = quill.Document();
}
} else {
doc = quill.Document();
}
setState(() {
_controller = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
_controller!.addListener(_onDocChanged);
_charCount = _controller!.document.length - 1; // 去掉末尾 \n
});
});
}
// ─── 文档变化监听 ─────────────────────────────────
void _onDocChanged() {
if (_controller == null) return;
final newCount = _controller!.document.length - 1;
if (newCount != _charCount) {
setState(() => _charCount = newCount);
}
// 自动保存(防抖 2 秒)
_autoSaveTimer?.cancel();
_autoSaveTimer = Timer(const Duration(seconds: 2), () {
if (mounted) _autoSave();
});
}
void _autoSave() {
final provider = context.read<NotePlusProvider>();
if (_controller == null) return;
provider.setTitle(_titleController.text);
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
provider.saveDocument(deltaJson: deltaJson);
}
void _save(NotePlusProvider provider) async {
_autoSaveTimer?.cancel();
if (_controller == null) return;
provider.setTitle(_titleController.text);
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
await provider.saveDocument(deltaJson: deltaJson);
if (mounted) ToastUtil.show(context, '已保存');
}
// ─── 构建 ─────────────────────────────────────
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Consumer<NotePlusProvider>(
builder: (context, provider, _) {
_initDocument(provider);
return PopScope(
canPop: !provider.isDirty,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && provider.isDirty) {
_showSaveDialog(provider);
} else {
_autoSave(); // 退出时自动保存
}
},
child: Scaffold(
backgroundColor: colors.surface,
appBar: _buildAppBar(provider, colors),
body: _controller == null
? const Center(child: CircularProgressIndicator())
: GestureDetector(
// 点击空白区域聚焦编辑器
onTap: () => _editorFocus.requestFocus(),
behavior: HitTestBehavior.translucent,
child: Column(
children: [
// 标题区
_buildTitleArea(colors),
// 分隔线
Divider(height: 1, color: colors.outlineVariant.withValues(alpha: 0.3)),
// 编辑器
Expanded(child: _buildEditor(colors)),
// 工具栏
_buildToolbar(colors),
],
),
),
),
);
},
);
}
// ─── AppBar ─────────────────────────────────────
PreferredSizeWidget _buildAppBar(NotePlusProvider provider, ColorScheme colors) {
return AppBar(
backgroundColor: colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, size: 22),
onPressed: () {
_autoSave();
Navigator.pop(context);
},
),
title: Text(
'$_charCount',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
),
centerTitle: true,
actions: [
// 未保存指示
if (provider.isDirty)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(
child: Container(
width: 6, height: 6,
decoration: BoxDecoration(color: colors.error, shape: BoxShape.circle),
),
),
),
IconButton(
icon: const Icon(Icons.undo, size: 20),
onPressed: _controller != null ? () => _controller!.undo() : null,
tooltip: '撤销',
),
IconButton(
icon: const Icon(Icons.redo, size: 20),
onPressed: _controller != null ? () => _controller!.redo() : null,
tooltip: '重做',
),
IconButton(
icon: Icon(Icons.check, size: 22, color: colors.primary),
onPressed: () => _save(provider),
tooltip: '保存',
),
],
);
}
// ─── 标题区 ─────────────────────────────────────
Widget _buildTitleArea(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: TextField(
controller: _titleController,
focusNode: _titleFocus,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: colors.onSurface,
height: 1.3,
),
decoration: InputDecoration(
hintText: '输入标题...',
hintStyle: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.15),
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
onChanged: (_) => context.read<NotePlusProvider>().setTitle(_titleController.text),
textInputAction: TextInputAction.next,
onSubmitted: (_) => _editorFocus.requestFocus(),
),
);
}
// ─── 编辑器 ─────────────────────────────────────
Widget _buildEditor(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TextSelectionTheme(
data: TextSelectionThemeData(
selectionColor: colors.primary.withValues(alpha: 0.12),
selectionHandleColor: colors.primary,
cursorColor: colors.primary,
),
child: quill.QuillEditor.basic(
controller: _controller!,
focusNode: _editorFocus,
scrollController: _scrollController,
config: const quill.QuillEditorConfig(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
autoFocus: false,
expands: true,
),
),
),
);
}
// ─── 工具栏 ─────────────────────────────────────
Widget _buildToolbar(ColorScheme colors) {
return Container(
decoration: BoxDecoration(
color: colors.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
child: SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: quill.QuillSimpleToolbar(
controller: _controller!,
config: quill.QuillSimpleToolbarConfig(
multiRowsDisplay: false,
color: Colors.transparent,
toolbarSize: 30,
buttonOptions: const quill.QuillSimpleToolbarButtonOptions(
base: quill.QuillToolbarBaseButtonOptions(iconSize: 17),
),
showBoldButton: true,
showItalicButton: true,
showUnderLineButton: true,
showStrikeThrough: true,
showHeaderStyle: false,
showListBullets: true,
showListNumbers: true,
showListCheck: true,
showCodeBlock: true,
showQuote: true,
showInlineCode: true,
showUndo: false,
showRedo: false,
showLink: false,
showSearchButton: false,
showFontSize: false,
showFontFamily: false,
showColorButton: false,
showBackgroundColorButton: false,
showClearFormat: false,
showAlignmentButtons: false,
showDirection: false,
showIndent: false,
showSubscript: false,
showSuperscript: false,
customButtons: [
quill.QuillToolbarCustomButtonOptions(
icon: const Icon(Icons.text_fields, size: 17, color: Color(0xFF555555)),
onPressed: () => _showHeaderPicker(colors),
),
],
),
),
),
),
);
}
// ─── 标题选择 ─────────────────────────────────
void _showHeaderPicker(ColorScheme colors) {
final current = _controller?.getSelectionStyle().attributes ?? {};
final currentHeader = current['header']?.value;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20),
],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示条
Container(
width: 36, height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 8),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(2),
),
),
_headerOption('正文', null, currentHeader == null, colors),
_headerOption('标题 1', 1, currentHeader == 1, colors),
_headerOption('标题 2', 2, currentHeader == 2, colors),
_headerOption('标题 3', 3, currentHeader == 3, colors),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _headerOption(String label, int? level, bool isActive, ColorScheme colors) {
final sizes = {null: 15.0, 1: 22.0, 2: 18.0, 3: 15.0};
return InkWell(
onTap: () {
Navigator.pop(context);
if (_controller == null) return;
if (level == null) {
_controller!.formatSelection(quill.Attribute.header);
} else {
_controller!.formatSelection(quill.Attribute.clone(quill.Attribute.header, level));
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(label, style: TextStyle(
fontSize: sizes[level] ?? 15,
fontWeight: level == null ? FontWeight.w400 : FontWeight.w600,
color: isActive ? colors.primary : colors.onSurface,
)),
),
if (isActive)
Icon(Icons.check, size: 18, color: colors.primary),
],
),
),
);
}
// ─── 保存对话框 ─────────────────────────────────
void _showSaveDialog(NotePlusProvider provider) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Text('未保存的更改',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
color: colors.onSurface)),
content: Text('是否保存当前文档?',
style: TextStyle(fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(
onPressed: () { Navigator.pop(ctx); Navigator.pop(context); },
child: Text('不保存', style: TextStyle(color: colors.error)),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('取消',
style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () { Navigator.pop(ctx); _save(provider); Navigator.pop(context); },
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary,
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('保存'),
),
],
),
);
}
}

View File

@@ -0,0 +1,657 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:expandable/expandable.dart';
import '../../providers/note_plus_provider.dart';
import '../../models/note_plus_models.dart';
/// Note Plus 树形文件列表页
class NotePlusTabPage extends StatefulWidget {
const NotePlusTabPage({super.key});
@override
State<NotePlusTabPage> createState() => _NotePlusTabPageState();
}
class _NotePlusTabPageState extends State<NotePlusTabPage> {
String? _selectedDocId;
String? _dragOverNodeId; // 拖到节点上(成为子节点)
_DropPosition? _dropPos; // 拖到节点之间(排序)
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NotePlusProvider>().loadDocuments();
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
_buildHeader(colors),
Expanded(child: _buildTree(colors)),
],
);
}
Widget _buildHeader(ColorScheme colors) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(children: [
Icon(Icons.edit_note, size: 18, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 8),
Text('Note Plus', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.7))),
const Spacer(),
GestureDetector(
onTap: () async {
final provider = context.read<NotePlusProvider>();
final doc = await provider.createDocument();
if (!mounted) return;
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
},
child: Icon(Icons.add, size: 20,
color: colors.onSurface.withValues(alpha: 0.45)),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => _showRecycleBin(context),
child: Icon(Icons.delete_outline, size: 18,
color: colors.onSurface.withValues(alpha: 0.35)),
),
]),
);
}
Widget _buildTree(ColorScheme colors) {
return Consumer<NotePlusProvider>(
builder: (context, provider, _) {
if (provider.documents.isEmpty) return _buildEmptyState(colors);
final roots = provider.documents
.where((d) => d.parentId.isEmpty)
.toList()
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
if (roots.isEmpty) return _buildEmptyState(colors);
return ExpandableTheme(
data: const ExpandableThemeData(
useInkWell: false,
animationDuration: Duration(milliseconds: 200),
headerAlignment: ExpandablePanelHeaderAlignment.center,
tapBodyToExpand: false, tapBodyToCollapse: false,
tapHeaderToExpand: false, iconPadding: EdgeInsets.zero, hasIcon: false,
),
child: RefreshIndicator(
onRefresh: () => provider.loadDocuments(),
child: ListView(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 40),
children: _buildChildrenList(roots, 0, colors),
),
),
);
},
);
}
/// 构建子节点列表,节点之间插入 drop zone共 n+1 个间隙)
List<Widget> _buildChildrenList(List<NotePlusDocument> docs, int depth, ColorScheme colors) {
final widgets = <Widget>[];
final parentId = docs.isNotEmpty ? docs.first.parentId : '';
for (int i = 0; i < docs.length; i++) {
// 节点前的间隙
widgets.add(_buildReorderGap(parentId, i, depth, colors));
// 节点本身
widgets.add(_buildTreeNode(docs[i], depth, colors));
}
// 末尾间隙
if (docs.isNotEmpty) {
widgets.add(_buildReorderGap(parentId, docs.length, depth, colors));
}
return widgets;
}
/// 排序间隙 drop zone
Widget _buildReorderGap(String parentId, int insertIndex, int depth, ColorScheme colors) {
final indent = 28.0 + depth * 18.0;
final isTarget = _dropPos != null &&
_dropPos!.parentId == parentId &&
_dropPos!.index == insertIndex;
return DragTarget<String>(
onWillAcceptWithDetails: (details) {
final draggedId = details.data;
final provider = context.read<NotePlusProvider>();
final dragged = provider.documents.where((d) => d.id == draggedId).firstOrNull;
if (dragged == null) return false;
// 防止拖到自己的子孙上
if (parentId.isNotEmpty &&
_isDescendantOf(provider.documents, childId: parentId, ancestorId: draggedId)) {
return false;
}
setState(() => _dropPos = _DropPosition(parentId, insertIndex));
return true;
},
onLeave: (_) {
if (_dropPos?.parentId == parentId && _dropPos?.index == insertIndex) {
setState(() => _dropPos = null);
}
},
onAcceptWithDetails: (details) {
setState(() { _dropPos = null; _dragOverNodeId = null; });
_handleDrop(details.data, parentId, insertIndex);
},
builder: (_, __, ___) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: isTarget ? 36 : 6,
margin: EdgeInsets.only(left: indent, right: 16),
decoration: isTarget
? BoxDecoration(
color: colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1),
)
: null,
child: isTarget
? Center(
child: Text('放置到此处',
style: TextStyle(fontSize: 11,
color: colors.primary.withValues(alpha: 0.7))),
)
: null,
);
},
);
}
/// 递归构建单个树节点
Widget _buildTreeNode(NotePlusDocument doc, int depth, ColorScheme colors) {
final provider = context.watch<NotePlusProvider>();
final children = provider.documents
.where((d) => d.parentId == doc.id)
.toList()
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
final hasChildren = children.isNotEmpty;
final isSelected = _selectedDocId == doc.id;
final isDropTarget = _dragOverNodeId == doc.id;
final nodeContent = _buildNodeContent(doc, depth,
hasChildren: hasChildren, isSelected: isSelected,
isDropTarget: isDropTarget, colors: colors);
// 可拖拽包装
final draggable = LongPressDraggable<String>(
data: doc.id,
delay: const Duration(milliseconds: 300),
feedback: Material(
elevation: 4, borderRadius: BorderRadius.circular(6),
child: Container(
width: MediaQuery.of(context).size.width - 48,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
),
child: Row(children: [
Icon(Icons.description_outlined, size: 14,
color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 8),
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
style: TextStyle(fontSize: 12, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
),
),
childWhenDragging: Opacity(opacity: 0.3, child: nodeContent),
child: DragTarget<String>(
onWillAcceptWithDetails: (details) {
final draggedId = details.data;
if (draggedId == doc.id) return false;
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
return false;
}
setState(() => _dragOverNodeId = doc.id);
return true;
},
onLeave: (_) {
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
},
onAcceptWithDetails: (details) {
setState(() { _dragOverNodeId = null; _dropPos = null; });
// 拖到节点上 → 成为该节点的子节点
provider.moveDocument(details.data, doc.id);
},
builder: (_, __, ___) => nodeContent,
),
);
// 组装:可展开节点带子树,叶子节点直接返回
if (hasChildren) {
return ExpandableNotifier(
initialExpanded: true,
child: Builder(
builder: (innerCtx) {
// 给 _buildNodeContent 传入 expandContext 用于展开/折叠
final nodeWithExpand = _buildNodeContent(doc, depth,
hasChildren: true, isSelected: isSelected,
isDropTarget: isDropTarget, expandContext: innerCtx, colors: colors);
final draggableWithExpand = LongPressDraggable<String>(
data: doc.id, delay: const Duration(milliseconds: 300),
feedback: Material(
elevation: 4, borderRadius: BorderRadius.circular(6),
child: Container(
width: MediaQuery.of(context).size.width - 48,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
),
child: Row(children: [
Icon(Icons.description_outlined, size: 14,
color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 8),
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
style: TextStyle(fontSize: 12, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
),
),
childWhenDragging: Opacity(opacity: 0.3, child: nodeWithExpand),
child: DragTarget<String>(
onWillAcceptWithDetails: (details) {
final draggedId = details.data;
if (draggedId == doc.id) return false;
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
return false;
}
setState(() => _dragOverNodeId = doc.id);
return true;
},
onLeave: (_) {
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
},
onAcceptWithDetails: (details) {
setState(() { _dragOverNodeId = null; _dropPos = null; });
provider.moveDocument(details.data, doc.id);
},
builder: (_, __, ___) => nodeWithExpand,
),
);
return Column(children: [
draggableWithExpand,
Expandable(
collapsed: const SizedBox(),
expanded: Column(children: _buildChildrenList(children, depth + 1, colors)),
),
]);
},
),
);
}
return draggable;
}
/// 节点内容(标题行)
Widget _buildNodeContent(NotePlusDocument doc, int depth,
{required bool hasChildren, required bool isSelected,
required bool isDropTarget, BuildContext? expandContext,
required ColorScheme colors}) {
final indent = 12.0 + depth * 18.0;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
setState(() => _selectedDocId = doc.id);
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
},
onLongPress: () => _showDocActions(doc),
borderRadius: BorderRadius.circular(4),
child: Container(
height: 30,
padding: EdgeInsets.only(left: indent, right: 8),
decoration: BoxDecoration(
color: isSelected
? colors.primary.withValues(alpha: 0.08)
: isDropTarget
? colors.primary.withValues(alpha: 0.12)
: null,
borderRadius: BorderRadius.circular(4),
border: isDropTarget
? Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1)
: null,
),
child: Row(children: [
// 展开/折叠箭头
if (hasChildren)
SizedBox(
width: 20, height: 20,
child: InkWell(
onTap: () {
if (expandContext != null) {
ExpandableController.of(expandContext,
rebuildOnChange: false, required: true)
?.toggle();
}
},
child: expandContext != null
? ExpandableIcon(
theme: ExpandableThemeData(
expandIcon: Icons.keyboard_arrow_right,
collapseIcon: Icons.keyboard_arrow_down,
iconColor: colors.onSurface.withValues(alpha: 0.4),
iconSize: 14, iconPadding: EdgeInsets.zero, hasIcon: false,
),
)
: Icon(Icons.keyboard_arrow_down, size: 14,
color: colors.onSurface.withValues(alpha: 0.4)),
),
)
else
const SizedBox(width: 20),
// 图标
Icon(_getDocIcon(doc), size: 14,
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 6),
// 标题
Expanded(
child: Text(doc.title.isEmpty ? '无标题' : doc.title,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.75),
), maxLines: 1, overflow: TextOverflow.ellipsis),
),
// 添加子文档
SizedBox(
width: 20, height: 20,
child: InkWell(
onTap: () => _createChildDoc(doc.id),
borderRadius: BorderRadius.circular(4),
child: Icon(Icons.add, size: 13,
color: colors.onSurface.withValues(alpha: 0.25)),
),
),
const SizedBox(width: 2),
// 删除
SizedBox(
width: 20, height: 20,
child: InkWell(
onTap: () => _showDeleteDialog(doc.id),
borderRadius: BorderRadius.circular(4),
child: Icon(Icons.delete_outline, size: 13,
color: colors.onSurface.withValues(alpha: 0.25)),
),
),
]),
),
),
);
}
// ─── 拖放处理 ─────────────────────────────────────
void _handleDrop(String draggedId, String targetParentId, int insertIndex) {
final provider = context.read<NotePlusProvider>();
provider.moveDocumentTo(draggedId, targetParentId, insertIndex);
}
// ─── 工具 ─────────────────────────────────────
bool _isDescendantOf(List<NotePlusDocument> docs,
{required String childId, required String ancestorId}) {
if (childId.isEmpty) return false;
if (childId == ancestorId) return true; // 自身也算(防止拖到自己下面)
var current = docs.where((d) => d.id == childId).firstOrNull;
while (current != null) {
if (current.parentId == ancestorId) return true;
current = docs.where((d) => d.id == current!.parentId).firstOrNull;
}
return false;
}
IconData _getDocIcon(NotePlusDocument doc) {
if (doc.blocks.isEmpty) return Icons.description_outlined;
switch (doc.blocks.first.type) {
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;
}
}
// ─── 操作 ─────────────────────────────────────
Future<void> _createChildDoc(String parentId) async {
final provider = context.read<NotePlusProvider>();
final doc = await provider.createDocument(parentId: parentId);
if (!mounted) return;
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
}
void _showDocActions(NotePlusDocument doc) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => 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),
),
),
ListTile(
leading: Icon(Icons.subdirectory_arrow_right, size: 20,
color: colors.onSurface.withValues(alpha: 0.6)),
title: const Text('新建子文档', style: TextStyle(fontSize: 14)),
onTap: () { Navigator.pop(ctx); _createChildDoc(doc.id); },
),
ListTile(
leading: Icon(Icons.edit_outlined, size: 20,
color: colors.onSurface.withValues(alpha: 0.6)),
title: const Text('重命名', style: TextStyle(fontSize: 14)),
onTap: () { Navigator.pop(ctx); _showRenameDialog(doc); },
),
ListTile(
leading: Icon(Icons.delete_outline, size: 20, color: colors.error),
title: Text('删除', style: TextStyle(fontSize: 14, color: colors.error)),
subtitle: Text('子文档将提升到上一级',
style: TextStyle(fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
onTap: () { Navigator.pop(ctx); _showDeleteDialog(doc.id); },
),
const SizedBox(height: 8),
]),
),
);
}
void _showRenameDialog(NotePlusDocument doc) {
final colors = Theme.of(context).colorScheme;
final controller = TextEditingController(text: doc.title);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('重命名', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
color: colors.onSurface)),
content: TextField(
controller: controller, autofocus: true,
decoration: InputDecoration(hintText: '输入标题',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
onSubmitted: (_) {
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
Navigator.pop(ctx);
},
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () {
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary,
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('确定'),
),
],
),
);
}
void _showDeleteDialog(String id) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('删除文档', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
color: colors.onSurface)),
content: Text('子文档将自动提升到上一级。可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
context.read<NotePlusProvider>().deleteDocument(id);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError,
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('删除'),
),
],
),
);
}
void _showRecycleBin(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final provider = context.read<NotePlusProvider>();
showModalBottomSheet(
context: context, backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.5, minChildSize: 0.3, maxChildSize: 0.8, expand: false,
builder: (ctx, scrollCtrl) {
return FutureBuilder<List<NotePlusDocument>>(
future: provider.getDeletedDocuments(),
builder: (context, snapshot) {
final deleted = snapshot.data ?? [];
return Column(children: [
Container(width: 36, height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 14),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Text('回收站', style: TextStyle(fontSize: 15,
fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
Text('${deleted.length} 个文档', style: TextStyle(fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
const SizedBox(height: 12),
Expanded(
child: deleted.isEmpty
? Center(child: Text('回收站为空',
style: TextStyle(fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.35))))
: ListView.builder(
controller: scrollCtrl, itemCount: deleted.length,
itemBuilder: (context, i) {
final doc = deleted[i];
return ListTile(
dense: true,
leading: Icon(Icons.description_outlined, size: 18,
color: colors.onSurface.withValues(alpha: 0.4)),
title: Text(doc.title.isEmpty ? '无标题' : doc.title,
style: const TextStyle(fontSize: 13)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(
icon: Icon(Icons.restore, size: 18, color: colors.primary),
onPressed: () { provider.restoreDocument(doc.id); Navigator.pop(ctx); },
),
IconButton(
icon: Icon(Icons.delete_forever, size: 18, color: colors.error),
onPressed: () { provider.permanentDeleteDocument(doc.id); Navigator.pop(ctx); },
),
]),
);
},
),
),
]);
},
);
},
),
);
}
Widget _buildEmptyState(ColorScheme colors) {
return Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 64, height: 64,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(Icons.edit_note, size: 32,
color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(height: 16),
Text('暂无文档', style: TextStyle(fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
Text('点击右上角 + 创建', style: TextStyle(fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.25))),
]),
);
}
}
/// 拖放位置记录
class _DropPosition {
final String parentId;
final int index;
_DropPosition(this.parentId, this.index);
}

View File

@@ -1100,6 +1100,7 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
bool _showNotePlusTab = false;
int _defaultTabIndex = 0;
@override
@@ -1113,6 +1114,7 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
_showNotePlusTab = _userPrefs.showNotePlusTab;
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
});
}
@@ -1143,6 +1145,11 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
setState(() => _showNoteTab = value);
}
Future<void> _toggleNotePlusTab(bool value) async {
await _userPrefs.setShowNotePlusTab(value);
setState(() => _showNotePlusTab = value);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
@@ -1161,6 +1168,8 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.note_outlined, '笔记', '记录和管理笔记', _showNoteTab, _toggleNoteTab),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档', _showNotePlusTab, _toggleNotePlusTab),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: Text('至少保留一个模块,关闭后对应标签页将不再显示。', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),

View File

@@ -0,0 +1,469 @@
import 'package:flutter/foundation.dart';
import '../models/note_plus_models.dart';
import '../utils/note_plus/note_plus_dao.dart';
/// Note Plus 块编辑器状态管理
class NotePlusProvider extends ChangeNotifier {
final NotePlusDao _dao = NotePlusDao();
List<NotePlusDocument> _documents = [];
NotePlusDocument? _currentDocument;
List<NoteBlock> _blocks = [];
int _focusedBlockIndex = 0;
bool _isDirty = false;
// Undo/Redo
final List<List<NoteBlock>> _undoStack = [];
final List<List<NoteBlock>> _redoStack = [];
static const int _maxUndoSize = 50;
// Getters
List<NotePlusDocument> get documents => _documents;
NotePlusDocument? get currentDocument => _currentDocument;
List<NoteBlock> get blocks => _blocks;
int get focusedBlockIndex => _focusedBlockIndex;
bool get isDirty => _isDirty;
bool get canUndo => _undoStack.isNotEmpty;
bool get canRedo => _redoStack.isNotEmpty;
// ========== 文档 CRUD ==========
Future<void> loadDocuments() async {
_documents = await _dao.getAll();
notifyListeners();
}
Future<void> loadDocumentById(String id) async {
_currentDocument = await _dao.getById(id);
if (_currentDocument != null) {
_blocks = _currentDocument!.blocks.map((b) => b.deepCopy()).toList();
_focusedBlockIndex = 0;
_isDirty = false;
_undoStack.clear();
_redoStack.clear();
}
notifyListeners();
}
Future<void> saveDocument({String? deltaJson}) async {
if (_currentDocument == null) return;
final now = DateTime.now();
// 直接构造,避免 copyWith 的 null 歧义
_currentDocument = NotePlusDocument(
id: _currentDocument!.id,
title: _currentDocument!.title,
parentId: _currentDocument!.parentId,
sortIndex: _currentDocument!.sortIndex,
blocks: deltaJson == null ? List.from(_blocks) : _currentDocument!.blocks,
blocksJson: deltaJson,
tags: _currentDocument!.tags,
images: _currentDocument!.images,
createdAt: _currentDocument!.createdAt,
updatedAt: now,
isDeleted: _currentDocument!.isDeleted,
);
await _dao.update(_currentDocument!);
final idx = _documents.indexWhere((d) => d.id == _currentDocument!.id);
if (idx >= 0) _documents[idx] = _currentDocument!;
_isDirty = false;
notifyListeners();
}
/// 保存 Quill Delta JSON 作为文档内容flutter_quill 模式)
void saveDeltaJson(String deltaJson) {
if (_currentDocument == null) return;
_currentDocument = NotePlusDocument(
id: _currentDocument!.id,
title: _currentDocument!.title,
parentId: _currentDocument!.parentId,
sortIndex: _currentDocument!.sortIndex,
blocksJson: deltaJson,
tags: _currentDocument!.tags,
images: _currentDocument!.images,
createdAt: _currentDocument!.createdAt,
updatedAt: DateTime.now(),
isDeleted: _currentDocument!.isDeleted,
);
_isDirty = true;
}
Future<NotePlusDocument> createDocument({String title = '', String parentId = ''}) async {
final doc = NotePlusDocument(title: title, parentId: parentId);
await _dao.insert(doc);
await loadDocuments();
return doc;
}
/// 移动文档到指定父级的指定位置(原子操作,同时处理重排序)
Future<void> moveDocumentTo(String docId, String newParentId, int insertIndex) async {
final idx = _documents.indexWhere((d) => d.id == docId);
if (idx < 0) return;
final doc = _documents[idx];
final oldParentId = doc.parentId;
// 获取目标父级下的同级文档(不含自身)
final siblings = _documents
.where((d) => d.parentId == newParentId && d.id != docId)
.toList()
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
// 钳制插入位置
var pos = insertIndex.clamp(0, siblings.length);
// 如果是同级移动,且拖到自己的后面,需要减 1因为移除自己后索引会偏移
if (oldParentId == newParentId) {
final oldIndex = siblings.indexWhere((d) => d.sortIndex > doc.sortIndex);
if (oldIndex >= 0 && pos > oldIndex) {
pos = (pos - 1).clamp(0, siblings.length);
}
}
// 插入到指定位置
siblings.insert(pos, doc);
// 批量更新 sortIndex 和 parentId
for (int i = 0; i < siblings.length; i++) {
final s = siblings[i];
final needsUpdate = s.sortIndex != i || s.id == docId && doc.parentId != newParentId;
if (needsUpdate) {
final updated = s.copyWith(
parentId: newParentId,
sortIndex: i,
updatedAt: DateTime.now(),
);
await _dao.update(updated);
final uIdx = _documents.indexWhere((d) => d.id == updated.id);
if (uIdx >= 0) _documents[uIdx] = updated;
}
}
notifyListeners();
}
/// 旧接口兼容:仅移动到新父级(放到末尾)
Future<void> moveDocument(String docId, String newParentId) async {
final siblings = _documents.where((d) => d.parentId == newParentId && d.id != docId).toList();
await moveDocumentTo(docId, newParentId, siblings.length);
}
/// 删除文档时,将其子文档提升到祖父节点
Future<void> deleteDocument(String id) async {
final idx = _documents.indexWhere((d) => d.id == id);
if (idx >= 0) {
final parentId = _documents[idx].parentId;
// 将子文档的 parent_id 改为被删文档的 parent_id
final children = _documents.where((d) => d.parentId == id).toList();
for (final child in children) {
final updated = child.copyWith(parentId: parentId, updatedAt: DateTime.now());
await _dao.update(updated);
}
}
await _dao.delete(id);
await loadDocuments();
}
Future<void> restoreDocument(String id) async {
await _dao.restore(id);
await loadDocuments();
}
Future<void> permanentDeleteDocument(String id) async {
await _dao.permanentDelete(id);
await loadDocuments();
}
Future<List<NotePlusDocument>> getDeletedDocuments() async {
return await _dao.getDeleted();
}
Future<void> searchDocuments(String query) async {
_documents = await _dao.search(query);
notifyListeners();
}
// ========== 文档属性 ==========
void setTitle(String title) {
if (_currentDocument == null) return;
_currentDocument = _currentDocument!.copyWith(title: title);
_isDirty = true;
notifyListeners();
}
/// 重命名文档(在文档列表中调用)
Future<void> renameDocument(String id, String newTitle) async {
final idx = _documents.indexWhere((d) => d.id == id);
if (idx < 0) return;
final doc = _documents[idx].copyWith(title: newTitle, updatedAt: DateTime.now());
await _dao.update(doc);
_documents[idx] = doc;
notifyListeners();
}
/// 更新文档所属文件夹
Future<void> updateDocumentFolder(String id, String folder) async {
final idx = _documents.indexWhere((d) => d.id == id);
if (idx < 0) return;
final doc = _documents[idx].copyWith(parentId: folder, updatedAt: DateTime.now());
await _dao.update(doc);
_documents[idx] = doc;
notifyListeners();
}
// ========== Block 操作 ==========
void setFocusedBlock(int index) {
if (index < 0 || index >= _blocks.length) return;
_focusedBlockIndex = index;
notifyListeners();
}
/// 添加 block在指定位置之后
void addBlock(int afterIndex, NoteBlock block) {
_pushUndo();
final insertAt = afterIndex + 1;
if (insertAt >= _blocks.length) {
_blocks.add(block);
} else {
_blocks.insert(insertAt, block);
}
_focusedBlockIndex = insertAt;
_isDirty = true;
notifyListeners();
}
/// 移除 block
void removeBlock(int index) {
if (_blocks.length <= 1 || index < 0 || index >= _blocks.length) return;
_pushUndo();
_blocks.removeAt(index);
if (_focusedBlockIndex >= _blocks.length) {
_focusedBlockIndex = _blocks.length - 1;
}
_isDirty = true;
notifyListeners();
}
/// 更新 block 内容(不触发 notify编辑时高频调用
void updateBlockSilent(int index, NoteBlock block) {
if (index < 0 || index >= _blocks.length) return;
_blocks[index] = block;
_isDirty = true;
}
/// 更新 block 并通知
void updateBlock(int index, NoteBlock block) {
updateBlockSilent(index, block);
notifyListeners();
}
/// 拖拽移动 block
void moveBlock(int oldIndex, int newIndex) {
if (oldIndex == newIndex) return;
_pushUndo();
final block = _blocks.removeAt(oldIndex);
final insertAt = newIndex > oldIndex ? newIndex - 1 : newIndex;
_blocks.insert(insertAt, block);
_focusedBlockIndex = insertAt;
_isDirty = true;
notifyListeners();
}
/// 转换 block 类型
void convertBlockType(int index, NoteBlockType newType) {
if (index < 0 || index >= _blocks.length) return;
_pushUndo();
_blocks[index] = _blocks[index].copyWith(type: newType);
if (newType == NoteBlockType.divider) {
_blocks[index] = _blocks[index].copyWith(text: '');
}
_isDirty = true;
notifyListeners();
}
/// Enter 分割 block
/// 返回新 block 的索引
int splitBlock(int index, int splitPosition) {
if (index < 0 || index >= _blocks.length) return index;
_pushUndo();
final current = _blocks[index];
final textBefore = current.text.substring(0, splitPosition);
final textAfter = current.text.substring(splitPosition);
// 裁剪当前 block 的格式
final beforeFormatting = _clipFormatting(current.formatting, 0, splitPosition);
// 新 block 继承类型heading 降为 paragraph
var newType = current.type;
if (newType == NoteBlockType.heading1 ||
newType == NoteBlockType.heading2 ||
newType == NoteBlockType.heading3) {
newType = NoteBlockType.paragraph;
}
// 平移新 block 的格式
final afterFormatting = _shiftFormatting(current.formatting, splitPosition, -splitPosition);
// 更新当前 block
_blocks[index] = current.copyWith(
text: textBefore,
formatting: beforeFormatting,
);
// 插入新 block
final newBlock = NoteBlock(
type: newType,
text: textAfter,
formatting: afterFormatting,
);
final insertAt = index + 1;
_blocks.insert(insertAt, newBlock);
_focusedBlockIndex = insertAt;
_isDirty = true;
notifyListeners();
return insertAt;
}
/// Backspace 合并到前一个 block
/// 返回合并后的光标位置
int mergeWithPrevious(int index) {
if (index <= 0 || index >= _blocks.length) return 0;
_pushUndo();
final prev = _blocks[index - 1];
final current = _blocks[index];
final cursorPos = prev.text.length;
// 合并文本
final mergedText = prev.text + current.text;
// 合并格式:平移当前 block 的格式
final shiftedFormatting = _shiftFormatting(current.formatting, 0, cursorPos);
final mergedFormatting = [...prev.formatting, ...shiftedFormatting];
// 更新前一个 block
_blocks[index - 1] = prev.copyWith(
text: mergedText,
formatting: mergedFormatting,
);
// 移除当前 block
_blocks.removeAt(index);
_focusedBlockIndex = index - 1;
_isDirty = true;
notifyListeners();
return cursorPos;
}
/// 切换待办状态
void toggleChecklist(int index) {
if (index < 0 || index >= _blocks.length) return;
final block = _blocks[index];
if (block.type != NoteBlockType.checklist) return;
_pushUndo();
final checked = block.metadata['checked'] == true;
_blocks[index] = block.copyWith(
metadata: {...block.metadata, 'checked': !checked},
);
_isDirty = true;
notifyListeners();
}
/// 应用内联格式
void applyFormat(int blockIndex, InlineFormatType format, int start, int end) {
if (blockIndex < 0 || blockIndex >= _blocks.length) return;
if (start >= end) return;
final block = _blocks[blockIndex];
final formatting = List<InlineFormatSpan>.from(block.formatting);
// 查找是否已有重叠的同格式 span
bool found = false;
for (int i = 0; i < formatting.length; i++) {
final span = formatting[i];
if (span.start <= start && span.end >= end && span.formats.contains(format)) {
// 移除格式
final newFormats = Set<InlineFormatType>.from(span.formats)..remove(format);
if (newFormats.isEmpty) {
formatting.removeAt(i);
} else {
formatting[i] = span.copyWith(formats: newFormats);
}
found = true;
break;
}
}
if (!found) {
formatting.add(InlineFormatSpan(start: start, end: end, formats: {format}));
}
_blocks[blockIndex] = block.copyWith(formatting: formatting);
_isDirty = true;
notifyListeners();
}
// ========== Undo/Redo ==========
void _pushUndo() {
_undoStack.add(_blocks.map((b) => b.deepCopy()).toList());
if (_undoStack.length > _maxUndoSize) {
_undoStack.removeAt(0);
}
_redoStack.clear();
}
void undo() {
if (_undoStack.isEmpty) return;
_redoStack.add(_blocks.map((b) => b.deepCopy()).toList());
_blocks = _undoStack.removeLast();
if (_focusedBlockIndex >= _blocks.length) {
_focusedBlockIndex = _blocks.length - 1;
}
_isDirty = true;
notifyListeners();
}
void redo() {
if (_redoStack.isEmpty) return;
_undoStack.add(_blocks.map((b) => b.deepCopy()).toList());
_blocks = _redoStack.removeLast();
if (_focusedBlockIndex >= _blocks.length) {
_focusedBlockIndex = _blocks.length - 1;
}
_isDirty = true;
notifyListeners();
}
// ========== 格式辅助 ==========
/// 裁剪格式区间到 [clipStart, clipEnd)
List<InlineFormatSpan> _clipFormatting(
List<InlineFormatSpan> spans, int clipStart, int clipEnd) {
return spans
.where((s) => s.end > clipStart && s.start < clipEnd)
.map((s) => InlineFormatSpan(
start: s.start < clipStart ? clipStart : s.start,
end: s.end > clipEnd ? clipEnd : s.end,
formats: Set.from(s.formats),
))
.toList();
}
/// 平移格式区间(从 fromPos 之后的 span 偏移 offset
List<InlineFormatSpan> _shiftFormatting(
List<InlineFormatSpan> spans, int fromPos, int offset) {
return spans
.where((s) => s.end > fromPos)
.map((s) => InlineFormatSpan(
start: (s.start < fromPos ? fromPos : s.start) + offset,
end: s.end + offset,
formats: Set.from(s.formats),
))
.where((s) => s.start >= 0 && s.end > s.start)
.toList();
}
}

View File

@@ -8,6 +8,8 @@ import '../pages/movies/movie_detail_page.dart';
import '../pages/book/book_detail_page.dart';
import '../pages/note/note_detail_page.dart';
import '../pages/movies/douban_webview_page.dart';
import '../pages/note_plus/note_plus_form_page.dart';
import '../pages/note_plus/note_plus_detail_page.dart';
/// 路由生成器
class AppRouter {
@@ -64,6 +66,20 @@ class AppRouter {
}
return SlideUpPageRoute(page: DoubanWebViewPage(url: url));
case '/note-plus-form':
final id = settings.arguments is String ? settings.arguments as String : null;
if (id == null) {
return _buildUnknownRoute(settings.name);
}
return SlideUpPageRoute(page: NotePlusFormPage(documentId: id));
case '/note-plus-detail':
final id = settings.arguments is String ? settings.arguments as String : null;
if (id == null) {
return _buildUnknownRoute(settings.name);
}
return SlideUpPageRoute(page: NotePlusDetailPage(documentId: id));
default:
return _buildUnknownRoute(settings.name);
}

View File

@@ -39,7 +39,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 17,
version: 21,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -125,6 +125,33 @@ class DatabaseHelper {
if (oldVersion < 17) {
await db.execute('ALTER TABLE tags ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0');
}
if (oldVersion < 18) {
await _createNotePlusTable(db);
}
if (oldVersion < 19) {
await _createNotePlusTable(db);
}
if (oldVersion < 20) {
// 确保 note_plus 表有 parent_id 列(从旧版 folder 迁移)
try {
final cols = await db.rawQuery('PRAGMA table_info(note_plus)');
if (!cols.any((col) => col['name'] == 'parent_id')) {
if (cols.any((col) => col['name'] == 'folder')) {
await db.execute("ALTER TABLE note_plus RENAME COLUMN folder TO parent_id");
} else {
await db.execute("ALTER TABLE note_plus ADD COLUMN parent_id TEXT DEFAULT ''");
}
}
} catch (_) {}
}
if (oldVersion < 21) {
try {
final cols = await db.rawQuery('PRAGMA table_info(note_plus)');
if (!cols.any((col) => col['name'] == 'sort_index')) {
await db.execute("ALTER TABLE note_plus ADD COLUMN sort_index INTEGER DEFAULT 0");
}
} catch (_) {}
}
}
/// 升级books表到V11添加ISBN和出版时间字段
@@ -639,6 +666,27 @@ class DatabaseHelper {
is_deleted INTEGER DEFAULT 0
)
''');
// Note Plus 块编辑器文档表
await _createNotePlusTable(db);
}
/// 创建 Note Plus 文档表
Future<void> _createNotePlusTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS note_plus (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '',
parent_id TEXT DEFAULT '',
sort_index INTEGER DEFAULT 0,
blocks_json TEXT NOT NULL,
tags TEXT,
images TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
}
// 关闭数据库

View File

@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import '../../models/note_plus_models.dart';
import '../database_helper.dart';
/// Note Plus 块文档数据访问对象
class NotePlusDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
try {
return await fn();
} catch (e) {
debugPrint('[NotePlusDao] $op error: $e');
rethrow;
}
}
// 获取所有未删除的文档
Future<List<NotePlusDocument>> getAll() => _wrap('getAll', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'note_plus',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'sort_index ASC, updated_at DESC',
);
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
});
// 分页查询
Future<List<NotePlusDocument>> getPaged({int limit = 20, int offset = 0}) =>
_wrap('getPaged', () async {
final db = await _dbHelper.database;
final maps = await db.query('note_plus', where: 'is_deleted = 0',
orderBy: 'sort_index ASC, updated_at DESC', limit: limit, offset: offset);
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
});
// 根据ID获取
Future<NotePlusDocument?> getById(String id) => _wrap('getById', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'note_plus',
where: 'id = ? AND is_deleted = ?',
whereArgs: [id, 0],
);
if (maps.isEmpty) return null;
return NotePlusDocument.fromJson(maps.first);
});
// 插入
Future<int> insert(NotePlusDocument doc) => _wrap('insert', () async {
final db = await _dbHelper.database;
return await db.insert('note_plus', doc.toJson());
});
// 更新
Future<int> update(NotePlusDocument doc) => _wrap('update', () async {
final db = await _dbHelper.database;
return await db.update(
'note_plus',
doc.toJson(),
where: 'id = ?',
whereArgs: [doc.id],
);
});
// 软删除
Future<int> delete(String id) => _wrap('delete', () async {
final db = await _dbHelper.database;
return await db.update(
'note_plus',
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
// ========== 回收站 ==========
Future<List<NotePlusDocument>> getDeleted() => _wrap('getDeleted', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'note_plus',
where: 'is_deleted = ?',
whereArgs: [1],
orderBy: 'sort_index ASC, updated_at DESC',
);
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
});
Future<int> restore(String id) => _wrap('restore', () async {
final db = await _dbHelper.database;
return await db.update(
'note_plus',
{'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
Future<int> permanentDelete(String id) => _wrap('permanentDelete', () async {
final db = await _dbHelper.database;
return await db.delete(
'note_plus',
where: 'id = ?',
whereArgs: [id],
);
});
// 搜索
Future<List<NotePlusDocument>> search(String query) => _wrap('search', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'note_plus',
where: '(title LIKE ? OR blocks_json LIKE ? OR tags LIKE ?) AND is_deleted = ?',
whereArgs: ['%$query%', '%$query%', '%$query%', 0],
orderBy: 'sort_index ASC, updated_at DESC',
);
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
});
// 根据标签筛选
Future<List<NotePlusDocument>> getByTag(String tag) => _wrap('getByTag', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'note_plus',
where: 'tags LIKE ? AND is_deleted = ?',
whereArgs: ['%$tag%', 0],
orderBy: 'sort_index ASC, updated_at DESC',
);
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
});
}

View File

@@ -89,6 +89,10 @@ class UserPrefs {
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
/// 是否显示 Note Plus 标签(默认关闭)
bool get showNotePlusTab => prefs.getBool('showNotePlusTab') ?? false;
Future<bool> setShowNotePlusTab(bool value) => prefs.setBool('showNotePlusTab', value);
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);

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);
}

View File

@@ -0,0 +1,161 @@
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)),
),
);
}
}