feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
All checks were successful
CI / Windows build (push) Successful in 14m22s
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>
This commit is contained in:
328
lib/editor/canvas/office_document_screen.dart
Normal file
328
lib/editor/canvas/office_document_screen.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user