Files
BadNote/lib/editor/canvas/office_document_screen.dart
Akiba So d346cc2670
All checks were successful
CI / Windows build (push) Successful in 14m22s
feat: unified shell, diagnostics pack, native Office, sticky board
Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:27 +08:00

329 lines
9.4 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';
/// 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 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;
}