flutter_rhwp 2026.6.2
flutter_rhwp: ^2026.6.2 copied to clipboard
Flutter bindings and widgets for rhwp.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_rhwp/flutter_rhwp.dart';
import 'package:flutter/services.dart' show rootBundle;
void main() {
runApp(const RhwpExampleApp());
}
const _sampleAssetPath = 'assets/korea_ai_action_plan_2026_2028.hwp';
const _sampleFileName = 'korea_ai_action_plan_2026_2028.hwp';
const _webEditorModuleUrl = String.fromEnvironment(
'RHWP_EDITOR_MODULE_URL',
defaultValue: RhwpWebEditor.defaultModuleUrl,
);
bool get _supportsFullEditorHost {
if (kIsWeb) {
return true;
}
return switch (defaultTargetPlatform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.linux ||
TargetPlatform.macOS ||
TargetPlatform.windows => true,
TargetPlatform.fuchsia => false,
};
}
typedef RhwpSampleBytesLoader = Future<Uint8List> Function();
enum _UnsavedChangesDecision { save, discard, cancel }
class _WriteExportedDocumentResult {
const _WriteExportedDocumentResult({
required this.status,
required this.completed,
});
final String status;
final bool completed;
}
class RhwpExampleApp extends StatefulWidget {
const RhwpExampleApp({
super.key,
this.autoOpenSample = true,
this.webEditorModuleUrl = _webEditorModuleUrl,
this.sampleBytesLoader,
});
final bool autoOpenSample;
final String webEditorModuleUrl;
final RhwpSampleBytesLoader? sampleBytesLoader;
@override
State<RhwpExampleApp> createState() => _RhwpExampleAppState();
}
class _RhwpExampleAppState extends State<RhwpExampleApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _editorController = RhwpEditorController();
final _fullEditorController = RhwpFullEditorController();
Key _viewerKey = UniqueKey();
RhwpDocument? _document;
RhwpDocumentMetadata? _metadata;
Uint8List? _sourceBytes;
Object? _error;
String? _fileName;
String? _status;
_EditorMode _editorMode = _supportsFullEditorHost
? _EditorMode.fullEditor
: _EditorMode.nativeEditor;
bool _busy = false;
bool _documentDirty = false;
bool get _usesFullEditor => _editorMode == _EditorMode.fullEditor;
@override
void initState() {
super.initState();
if (widget.autoOpenSample) {
_openSampleDocument();
}
}
@override
void dispose() {
_document?.close();
_editorController.dispose();
_fullEditorController.dispose();
super.dispose();
}
Future<void> _createBlankDocument() async {
if (!await _confirmUnsavedChanges(RhwpEditorFileAction.newDocument)) {
return;
}
await _run('New document', () async {
if (_usesFullEditor) {
await _replaceWebEditorSource(fileName: 'blank.hwp');
return 'Created blank.hwp in full editor';
}
final next = await Rhwp.createEmpty(fileName: 'blank.hwp');
final bytes = await next.exportHwp();
await _replaceDocument(next, fileName: 'blank.hwp', sourceBytes: bytes);
return 'Created blank.hwp';
});
}
Future<void> _openSampleDocument() async {
if (!await _confirmUnsavedChanges(RhwpEditorFileAction.openDocument)) {
return;
}
await _run('Open sample asset', () async {
final bytes = await _loadSampleBytes();
if (_usesFullEditor) {
await _replaceWebEditorSource(
fileName: _sampleFileName,
sourceBytes: bytes,
);
return 'Opened bundled sample in full editor';
}
final next = await Rhwp.open(bytes, fileName: _sampleFileName);
await _replaceDocument(
next,
fileName: _sampleFileName,
sourceBytes: bytes,
);
return 'Opened bundled sample';
});
}
Future<Uint8List> _loadSampleBytes() async {
final loader = widget.sampleBytesLoader;
if (loader != null) {
return loader();
}
final data = await rootBundle.load(_sampleAssetPath);
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
}
Future<void> _openDocument() async {
if (!await _confirmUnsavedChanges(RhwpEditorFileAction.openDocument)) {
return;
}
await _run('Open document', () async {
final result = await FilePicker.pickFiles(
dialogTitle: 'Open HWP/HWPX',
type: FileType.custom,
allowedExtensions: const ['hwp', 'hwpx'],
withData: true,
);
if (result == null || result.files.isEmpty) {
return null;
}
final file = result.files.single;
final bytes = await file.xFile.readAsBytes();
if (_usesFullEditor) {
await _replaceWebEditorSource(fileName: file.name, sourceBytes: bytes);
return 'Opened ${file.name} in full editor';
}
final next = await Rhwp.open(bytes, fileName: file.name);
await _replaceDocument(next, fileName: file.name, sourceBytes: bytes);
return 'Opened ${file.name}';
});
}
Future<void> _closeDocument() async {
if (!await _confirmUnsavedChanges(RhwpEditorFileAction.closeDocument)) {
return;
}
await _run('Close document', () async {
final previous = _document;
setState(() {
_document = null;
_metadata = null;
_sourceBytes = null;
_fileName = null;
_documentDirty = false;
_viewerKey = UniqueKey();
});
await previous?.close();
return 'Closed document';
});
}
Future<RhwpEditorImage?> _pickEditorImage() async {
final result = await FilePicker.pickFiles(
dialogTitle: 'Insert picture',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'bmp', 'gif'],
withData: true,
);
if (result == null || result.files.isEmpty) {
return null;
}
final file = result.files.single;
final bytes = await file.xFile.readAsBytes();
final extension = file.extension ?? file.name.split('.').last;
return RhwpEditorImage(
bytes: bytes,
extension: extension,
description: file.name,
);
}
Future<void> _saveExport(_ExportKind kind) async {
final document = _document;
if (document == null && !_usesFullEditor) {
_showStatus('No document is open');
return;
}
await _run('Export ${kind.extension.toUpperCase()}', () async {
final exported = await _exportFor(kind, document: document);
final status = await _writeExportedDocument(
exported,
dialogLabel: kind.label,
);
if (status.completed &&
(kind == _ExportKind.hwp || kind == _ExportKind.hwpx)) {
setState(() {
_documentDirty = false;
});
}
return status.status;
});
}
Future<void> _saveEditorExport(RhwpExportedDocument exported) async {
await _run('Save ${exported.fileName}', () async {
final result = await _writeExportedDocument(exported);
return result.status;
});
}
Future<bool> _confirmUnsavedChanges(RhwpEditorFileAction action) async {
if (!_documentDirty && !_editorController.dirty) {
return true;
}
final decision = await showDialog<_UnsavedChangesDecision>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Save changes?'),
content: Text(
'The current document has unsaved changes. Save before ${_fileActionLabel(action)}?',
),
actions: [
TextButton(
key: const ValueKey('rhwp-unsaved-cancel'),
onPressed: () =>
Navigator.of(context).pop(_UnsavedChangesDecision.cancel),
child: const Text('Cancel'),
),
TextButton(
key: const ValueKey('rhwp-unsaved-discard'),
onPressed: () =>
Navigator.of(context).pop(_UnsavedChangesDecision.discard),
child: const Text('Discard'),
),
FilledButton(
key: const ValueKey('rhwp-unsaved-save'),
onPressed: () =>
Navigator.of(context).pop(_UnsavedChangesDecision.save),
child: const Text('Save'),
),
],
),
);
if (!mounted) {
return false;
}
return switch (decision) {
_UnsavedChangesDecision.save => _saveCurrentDocumentBeforeFileAction(),
_UnsavedChangesDecision.discard => _discardCurrentDocumentChanges(),
_UnsavedChangesDecision.cancel || null => false,
};
}
String _fileActionLabel(RhwpEditorFileAction action) {
return switch (action) {
RhwpEditorFileAction.newDocument => 'creating a new document',
RhwpEditorFileAction.openDocument => 'opening another document',
RhwpEditorFileAction.closeDocument => 'closing the document',
};
}
Future<bool> _saveCurrentDocumentBeforeFileAction() async {
try {
final exported = await _exportFor(_ExportKind.hwp, document: _document);
final result = await _writeExportedDocument(exported, dialogLabel: 'HWP');
if (!mounted) {
return false;
}
_showStatus(result.status);
if (!result.completed) {
return false;
}
setState(() {
_documentDirty = false;
});
_editorController.markClean();
return true;
} catch (error) {
if (!mounted) {
return false;
}
setState(() {
_error = error;
_status = 'Failed';
});
_showStatus(error.toString());
return false;
}
}
bool _discardCurrentDocumentChanges() {
setState(() {
_documentDirty = false;
});
_editorController.markClean();
return true;
}
Future<void> _printEditorDocument(RhwpExportedDocument exported) async {
await _run('Print ${exported.fileName}', () async {
final result = await _writeExportedDocument(
exported,
dialogLabel: 'Print PDF',
);
return result.status;
});
}
Future<_WriteExportedDocumentResult> _writeExportedDocument(
RhwpExportedDocument exported, {
String? dialogLabel,
}) async {
final path = await FilePicker.saveFile(
dialogTitle:
'Save ${dialogLabel ?? exported.format.fileExtension.toUpperCase()}',
fileName: exported.fileName,
type: FileType.custom,
allowedExtensions: [exported.format.fileExtension],
bytes: exported.bytes,
);
if (path == null) {
if (kIsWeb) {
return _WriteExportedDocumentResult(
status: 'Started ${exported.fileName} download',
completed: true,
);
}
return const _WriteExportedDocumentResult(
status: 'Save cancelled',
completed: false,
);
}
return _WriteExportedDocumentResult(status: 'Saved $path', completed: true);
}
Future<void> _insertDemoText() async {
final document = _document;
if (document == null) {
_showStatus(
_usesFullEditor
? 'Use the full editor toolbar for direct edits'
: 'No document is open',
);
return;
}
await _run('Insert text', () async {
await document.insertText(
section: 0,
paragraph: 0,
offset: 0,
text: 'flutter_rhwp ',
);
_viewerKey = UniqueKey();
_metadata = await document.metadata();
return 'Inserted text at section 0, paragraph 0';
});
}
Future<RhwpExportedDocument> _exportFor(
_ExportKind kind, {
required RhwpDocument? document,
}) async {
if (_usesFullEditor) {
return _fullEditorController.exportDocument(
kind.format,
sourceFileName: _fileName,
page: kind.defaultPage,
);
}
if (document == null) {
throw StateError('No document is open');
}
return document.exportDocument(
kind.format,
sourceFileName: _fileName,
page: kind.defaultPage,
);
}
Future<void> _replaceDocument(
RhwpDocument next, {
required String fileName,
Uint8List? sourceBytes,
bool dirty = false,
}) async {
final previous = _document;
final metadata = await next.metadata();
setState(() {
_document = next;
_metadata = metadata;
_sourceBytes = sourceBytes;
_fileName = fileName;
_documentDirty = dirty;
_viewerKey = UniqueKey();
});
_editorController.dirty = dirty;
await previous?.close();
}
Future<void> _replaceWebEditorSource({
required String fileName,
Uint8List? sourceBytes,
bool dirty = false,
}) async {
final previous = _document;
setState(() {
_document = null;
_metadata = null;
_sourceBytes = sourceBytes;
_fileName = fileName;
_documentDirty = dirty;
_viewerKey = UniqueKey();
});
_editorController.dirty = dirty;
await previous?.close();
}
Future<Uint8List> _sourceBytesForNativeEditor() async {
if (_usesFullEditor && _fullEditorController.isAttached) {
final exported = await _fullEditorController.exportDocument(
RhwpExportFormat.hwp,
sourceFileName: _fileName,
);
return exported.bytes;
}
final sourceBytes = _sourceBytes;
if (sourceBytes == null) {
throw StateError('No HWP/HWPX bytes are available for native editor');
}
return sourceBytes;
}
Future<void> _setEditorMode(_EditorMode mode) async {
if (mode == _editorMode) {
return;
}
if (mode == _EditorMode.fullEditor && !_supportsFullEditorHost) {
_showStatus('The rhwp full editor is not available on this platform');
return;
}
final nativeDocument = _document;
if (mode == _EditorMode.fullEditor && nativeDocument != null) {
await _run('Open full editor', () async {
final dirty = _documentDirty || _editorController.dirty;
final sourceBytes = await nativeDocument.exportHwp();
await _replaceWebEditorSource(
fileName: _fileName ?? 'document.hwp',
sourceBytes: sourceBytes,
dirty: dirty,
);
setState(() {
_editorMode = mode;
});
return 'Switched to full editor';
});
return;
}
if (mode == _EditorMode.nativeEditor &&
_document == null &&
_sourceBytes == null &&
!_fullEditorController.isAttached) {
_showStatus('Open a HWP/HWPX file before switching to native editor');
return;
}
if (mode == _EditorMode.nativeEditor && _document == null) {
await _run('Open native editor', () async {
final sourceBytes = await _sourceBytesForNativeEditor();
final dirty = _documentDirty;
final next = await Rhwp.open(
sourceBytes,
fileName: _fileName ?? 'document.hwp',
);
await _replaceDocument(
next,
fileName: _fileName ?? 'document.hwp',
sourceBytes: sourceBytes,
dirty: dirty,
);
setState(() {
_editorMode = mode;
});
return 'Switched to native editor';
});
return;
}
setState(() {
_editorMode = mode;
});
}
Future<void> _run(String label, Future<String?> Function() task) async {
setState(() {
_busy = true;
_error = null;
_status = label;
});
try {
final status = await task();
if (!mounted) {
return;
}
setState(() {
_busy = false;
_status = status ?? 'Ready';
});
if (status != null) {
_showStatus(status);
}
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_busy = false;
_error = error;
_status = 'Failed';
});
_showStatus(error.toString());
}
}
void _showStatus(String message) {
if (!mounted) {
return;
}
final messenger = _scaffoldMessengerKey.currentState;
if (messenger == null) {
return;
}
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final document = _document;
final canShowWebEditor =
_usesFullEditor && (_fileName != null || _sourceBytes != null);
final canExport = document != null || canShowWebEditor;
return MaterialApp(
scaffoldMessengerKey: _scaffoldMessengerKey,
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('flutter_rhwp'),
actions: [
IconButton(
tooltip: 'Open bundled sample',
onPressed: _busy ? null : _openSampleDocument,
icon: const Icon(Icons.article_outlined),
),
IconButton(
tooltip: 'Open',
onPressed: _busy ? null : _openDocument,
icon: const Icon(Icons.folder_open),
),
IconButton(
tooltip: 'New',
onPressed: _busy ? null : _createBlankDocument,
icon: const Icon(Icons.note_add_outlined),
),
IconButton(
tooltip: 'Insert text command',
onPressed: _busy || document == null ? null : _insertDemoText,
icon: const Icon(Icons.edit_outlined),
),
PopupMenuButton<_ExportKind>(
tooltip: 'Export',
enabled: !_busy && canExport,
icon: const Icon(Icons.ios_share),
onSelected: _saveExport,
itemBuilder: (context) => const [
PopupMenuItem(value: _ExportKind.hwp, child: Text('HWP')),
PopupMenuItem(value: _ExportKind.hwpx, child: Text('HWPX')),
PopupMenuItem(value: _ExportKind.pdf, child: Text('PDF')),
PopupMenuItem(value: _ExportKind.docx, child: Text('DOCX')),
PopupMenuItem(value: _ExportKind.svg, child: Text('SVG')),
PopupMenuItem(value: _ExportKind.text, child: Text('Text')),
PopupMenuItem(
value: _ExportKind.markdown,
child: Text('Markdown'),
),
],
),
IconButton(
tooltip: 'Zoom out',
onPressed: _editorController.zoomOut,
icon: const Icon(Icons.zoom_out),
),
IconButton(
tooltip: 'Zoom in',
onPressed: _editorController.zoomIn,
icon: const Icon(Icons.zoom_in),
),
],
),
body: Column(
children: [
_StatusBar(
busy: _busy,
fileName: _fileName,
metadata: _metadata,
dirty: _documentDirty,
status: _status,
error: _error,
editorMode: _editorMode,
onEditorModeChanged: _supportsFullEditorHost && !_busy
? _setEditorMode
: null,
),
Expanded(
child: _busy && !canExport
? const Center(child: CircularProgressIndicator())
: _error != null && !canExport
? Center(child: Text(_error.toString()))
: canShowWebEditor
? RhwpFullEditor(
key: ValueKey(
'full-editor-${_fileName ?? 'document'}-${_sourceBytes?.length ?? 0}-${_viewerKey.hashCode}',
),
controller: _fullEditorController,
moduleUrl: widget.webEditorModuleUrl,
initialBytes: _sourceBytes,
fileName: _fileName,
)
: document == null
? const SizedBox.shrink()
: RhwpNativeEditor(
key: _viewerKey,
document: document,
controller: _editorController,
editRefreshDelay: const Duration(seconds: 5),
holdTextRefreshWhileFocused: true,
onNewRequested: _busy ? null : _createBlankDocument,
onOpenRequested: _busy ? null : _openDocument,
onCloseRequested: _busy ? null : _closeDocument,
onUnsavedChanges: _confirmUnsavedChanges,
onImageRequested: _busy ? null : _pickEditorImage,
onExported: _busy ? null : _saveEditorExport,
onPrintRequested: _busy ? null : _printEditorDocument,
onDirtyChanged: (dirty) {
setState(() {
_documentDirty = dirty;
});
},
onChanged: (_) async {
final metadata = await document.metadata();
if (!mounted) {
return;
}
setState(() {
_metadata = metadata;
});
},
),
),
],
),
),
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({
required this.busy,
required this.fileName,
required this.metadata,
required this.dirty,
required this.status,
required this.error,
required this.editorMode,
required this.onEditorModeChanged,
});
final bool busy;
final String? fileName;
final RhwpDocumentMetadata? metadata;
final bool dirty;
final String? status;
final Object? error;
final _EditorMode editorMode;
final ValueChanged<_EditorMode>? onEditorModeChanged;
@override
Widget build(BuildContext context) {
final text = [
'${fileName ?? 'No file'}${dirty ? ' *' : ''}',
if (metadata != null) '${metadata!.pageCount} page(s)',
if (metadata != null) metadata!.sourceFormat.toUpperCase(),
if (error != null) 'Error',
?status,
].join(' | ');
return Material(
color: error == null ? const Color(0xfff8fafc) : const Color(0xfffffbfa),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
if (busy)
const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
Expanded(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (onEditorModeChanged != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: SegmentedButton<_EditorMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
),
segments: const [
ButtonSegment(
value: _EditorMode.nativeEditor,
icon: Icon(Icons.integration_instructions_outlined),
label: Text('Native editor'),
),
ButtonSegment(
value: _EditorMode.fullEditor,
icon: Icon(Icons.web_asset_outlined),
label: Text('Full editor'),
),
],
selected: {editorMode},
onSelectionChanged: (selected) {
if (selected.isNotEmpty) {
onEditorModeChanged!(selected.first);
}
},
),
),
],
),
),
);
}
}
enum _ExportKind {
hwp('HWP', RhwpExportFormat.hwp),
hwpx('HWPX', RhwpExportFormat.hwpx),
pdf('PDF', RhwpExportFormat.pdf),
docx('DOCX', RhwpExportFormat.docx),
svg('SVG', RhwpExportFormat.svg, defaultPage: 0),
text('text', RhwpExportFormat.text),
markdown('Markdown', RhwpExportFormat.markdown);
const _ExportKind(this.label, this.format, {this.defaultPage});
final String label;
final RhwpExportFormat format;
final int? defaultPage;
String get extension => format.fileExtension;
}
enum _EditorMode { nativeEditor, fullEditor }