All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text editing (size/bold/drag/double-tap), index SidecarText in search, and share keyboard page shortcuts plus a PDF scrubber. Co-authored-by: Cursor <cursoragent@cursor.com>
338 lines
9.8 KiB
Dart
338 lines
9.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../../diagnostics/badnote_log.dart';
|
|
import '../../diagnostics/pen_event_ring.dart';
|
|
import '../../services/office/docx_parser.dart';
|
|
import '../../services/office/office_document.dart';
|
|
import '../../services/office/pptx_parser.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../ui/page_nav_shortcuts.dart';
|
|
|
|
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
|
|
class OfficeDocumentScreen extends StatefulWidget {
|
|
const OfficeDocumentScreen({
|
|
super.key,
|
|
required this.filePath,
|
|
});
|
|
|
|
final String filePath;
|
|
|
|
@override
|
|
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
|
|
}
|
|
|
|
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
|
|
bool _loading = true;
|
|
String? _error;
|
|
ParsedPptx? _pptx;
|
|
ParsedDocx? _docx;
|
|
int _pageIndex = 0;
|
|
final List<_InkStroke> _strokes = [];
|
|
_InkStroke? _live;
|
|
final TransformationController _transform = TransformationController();
|
|
|
|
String get _sidecarPath => '${widget.filePath}.badnote.json';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_open();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_transform.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _open() async {
|
|
final ext = p.extension(widget.filePath).toLowerCase();
|
|
try {
|
|
if (ext == '.pptx' || ext == '.ppt') {
|
|
_pptx = await PptxParser().parse(widget.filePath);
|
|
} else if (ext == '.docx') {
|
|
_docx = await DocxParser().parse(widget.filePath);
|
|
} else {
|
|
throw StateError('Unsupported: $ext');
|
|
}
|
|
await _loadSidecar();
|
|
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
|
|
'path': widget.filePath,
|
|
'pages': pageCount,
|
|
});
|
|
} catch (e) {
|
|
_error = '$e';
|
|
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
|
|
'error': '$e',
|
|
});
|
|
}
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
int get pageCount {
|
|
if (_pptx != null) return _pptx!.slides.length;
|
|
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
|
|
return 0;
|
|
}
|
|
|
|
Future<void> _loadSidecar() async {
|
|
final f = File(_sidecarPath);
|
|
if (!await f.exists()) return;
|
|
try {
|
|
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
final pages = json['pages'] as Map<String, dynamic>? ?? {};
|
|
final key = '$_pageIndex';
|
|
final list = pages[key] as List<dynamic>? ?? [];
|
|
_strokes
|
|
..clear()
|
|
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _saveSidecar() async {
|
|
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
|
|
final f = File(_sidecarPath);
|
|
if (await f.exists()) {
|
|
try {
|
|
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
} catch (_) {}
|
|
}
|
|
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
|
|
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
|
|
root['pages'] = pages;
|
|
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
|
|
}
|
|
|
|
Future<void> _goPage(int i) async {
|
|
await _saveSidecar();
|
|
setState(() {
|
|
_pageIndex = i.clamp(0, pageCount - 1);
|
|
_strokes.clear();
|
|
_live = null;
|
|
});
|
|
await _loadSidecar();
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _onPointerDown(PointerDownEvent e) {
|
|
if (e.kind != ui.PointerDeviceKind.stylus &&
|
|
e.kind != ui.PointerDeviceKind.invertedStylus &&
|
|
e.kind != ui.PointerDeviceKind.mouse) {
|
|
return;
|
|
}
|
|
final local = _toScene(e.localPosition);
|
|
_live = _InkStroke(points: [local], pressures: [e.pressure]);
|
|
PenEventRing.instance.recordPointer(
|
|
kind: 'down',
|
|
pointerId: e.pointer,
|
|
deviceKind: e.kind.name,
|
|
pressure: e.pressure,
|
|
decision: 'draw',
|
|
);
|
|
setState(() {});
|
|
}
|
|
|
|
void _onPointerMove(PointerMoveEvent e) {
|
|
final live = _live;
|
|
if (live == null) return;
|
|
live.points.add(_toScene(e.localPosition));
|
|
live.pressures.add(e.pressure);
|
|
setState(() {});
|
|
}
|
|
|
|
void _onPointerUp(PointerUpEvent e) {
|
|
final live = _live;
|
|
if (live == null) return;
|
|
setState(() {
|
|
_strokes.add(live);
|
|
_live = null;
|
|
});
|
|
unawaited(_saveSidecar());
|
|
}
|
|
|
|
Offset _toScene(Offset local) {
|
|
final inv = Matrix4.inverted(_transform.value);
|
|
return MatrixUtils.transformPoint(inv, local);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_loading) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
if (_error != null) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(p.basename(widget.filePath))),
|
|
body: Center(child: Text(_error!)),
|
|
);
|
|
}
|
|
|
|
return pageNavShortcuts(
|
|
onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
|
onNext:
|
|
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
|
|
onFirst: pageCount > 0 ? () => _goPage(0) : null,
|
|
onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(p.basename(widget.filePath)),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
|
icon: const Icon(Icons.chevron_left),
|
|
),
|
|
Center(child: Text('${_pageIndex + 1} / $pageCount')),
|
|
IconButton(
|
|
onPressed: _pageIndex < pageCount - 1
|
|
? () => _goPage(_pageIndex + 1)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_right),
|
|
),
|
|
],
|
|
),
|
|
body: InteractiveViewer(
|
|
transformationController: _transform,
|
|
minScale: 0.5,
|
|
maxScale: 4,
|
|
child: Listener(
|
|
onPointerDown: _onPointerDown,
|
|
onPointerMove: _onPointerMove,
|
|
onPointerUp: _onPointerUp,
|
|
child: CustomPaint(
|
|
painter: _OfficePagePainter(
|
|
pptx: _pptx,
|
|
docx: _docx,
|
|
pageIndex: _pageIndex,
|
|
strokes: _strokes,
|
|
live: _live,
|
|
),
|
|
size: _pageSize,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Size get _pageSize {
|
|
if (_pptx != null && _pptx!.slides.isNotEmpty) {
|
|
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
|
|
return Size(s.width, s.height);
|
|
}
|
|
return const Size(800, 1100);
|
|
}
|
|
}
|
|
|
|
class _InkStroke {
|
|
_InkStroke({required this.points, required this.pressures});
|
|
|
|
final List<Offset> points;
|
|
final List<double> pressures;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'points': [
|
|
for (final p in points) {'x': p.dx, 'y': p.dy},
|
|
],
|
|
'pressures': pressures,
|
|
};
|
|
|
|
factory _InkStroke.fromJson(Map<String, dynamic> json) {
|
|
final pts = (json['points'] as List<dynamic>)
|
|
.map((e) => Offset(
|
|
(e['x'] as num).toDouble(),
|
|
(e['y'] as num).toDouble(),
|
|
))
|
|
.toList();
|
|
final pr = (json['pressures'] as List<dynamic>?)
|
|
?.map((e) => (e as num).toDouble())
|
|
.toList() ??
|
|
List.filled(pts.length, 0.5);
|
|
return _InkStroke(points: pts, pressures: pr);
|
|
}
|
|
}
|
|
|
|
class _OfficePagePainter extends CustomPainter {
|
|
_OfficePagePainter({
|
|
required this.pptx,
|
|
required this.docx,
|
|
required this.pageIndex,
|
|
required this.strokes,
|
|
required this.live,
|
|
});
|
|
|
|
final ParsedPptx? pptx;
|
|
final ParsedDocx? docx;
|
|
final int pageIndex;
|
|
final List<_InkStroke> strokes;
|
|
final _InkStroke? live;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final bg = Paint()..color = AppTokens.paper;
|
|
canvas.drawRect(Offset.zero & size, bg);
|
|
|
|
if (pptx != null && pptx!.slides.isNotEmpty) {
|
|
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
|
|
final border = Paint()
|
|
..color = AppTokens.rule
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
|
|
for (final run in slide.runs) {
|
|
final tp = TextPainter(
|
|
text: TextSpan(
|
|
text: run.text,
|
|
style: TextStyle(
|
|
color: AppTokens.ink,
|
|
fontSize: run.fontSize,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
|
|
tp.paint(canvas, Offset(run.x, run.y));
|
|
}
|
|
} else if (docx != null) {
|
|
final start = pageIndex * 12;
|
|
final blocks = docx!.blocks.skip(start).take(12).toList();
|
|
var y = 48.0;
|
|
for (final b in blocks) {
|
|
final style = TextStyle(
|
|
color: AppTokens.ink,
|
|
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
|
|
fontWeight:
|
|
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
|
|
);
|
|
final tp = TextPainter(
|
|
text: TextSpan(text: b.text, style: style),
|
|
textDirection: TextDirection.ltr,
|
|
)..layout(maxWidth: size.width - 96);
|
|
tp.paint(canvas, Offset(48, y));
|
|
y += tp.height + 12;
|
|
}
|
|
}
|
|
|
|
final ink = Paint()
|
|
..color = AppTokens.copper
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2.2
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
for (final s in [...strokes, if (live != null) live!]) {
|
|
if (s.points.length < 2) continue;
|
|
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
|
|
for (var i = 1; i < s.points.length; i++) {
|
|
path.lineTo(s.points[i].dx, s.points[i].dy);
|
|
}
|
|
canvas.drawPath(path, ink);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
|
|
}
|