You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
9.3 KiB
296 lines
9.3 KiB
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
|
|
import 'meeting_details_controller.dart';
|
|
|
|
/// 思维导图控制器
|
|
class MindMapController extends GetxController {
|
|
late final WebViewController webViewController;
|
|
final _meetingDetailsController = Get.find<MeetingDetailsController>();
|
|
final double _width = 1.sw - 25.w;
|
|
bool isWebViewReady = false;
|
|
|
|
int imageType = 1;
|
|
|
|
late final Worker _summaryNumberListener;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
final String summaryJs =
|
|
_jsString(_meetingDetailsController.meetingDetails.value.summary);
|
|
String html = '''
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>MindMap</title>
|
|
<script>${_meetingDetailsController.markmapLibJs}</script>
|
|
<script>${_meetingDetailsController.d3Js}</script>
|
|
<script>${_meetingDetailsController.markmapViewJs}</script>
|
|
<script>${_meetingDetailsController.markmapToolbarJs}</script>
|
|
<script>${_meetingDetailsController.saveSvgAsPngJs}</script>
|
|
<style>
|
|
:root {
|
|
--bg-color: #FFFFFF;
|
|
--text-color: #000000;
|
|
--toolbar-bg: #FFFFFF;
|
|
--toolbar-border: #d0d5dd;
|
|
--toolbar-text: #101828;
|
|
--toolbar-active: #d4d4d8;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-width: ${_width}px;
|
|
min-height: ${_width}px;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
background-color: var(--bg-color);
|
|
transition: background-color 0.3s ease;
|
|
}
|
|
|
|
.mind-map {
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mind-map #markmap {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.mind-map #markmap .markmap-node-text {
|
|
fill: var(--text-color) !important;
|
|
transition: fill 0.3s ease;
|
|
}
|
|
|
|
.mind-map .mm-toolbar {
|
|
height: 30px;
|
|
background: var(--toolbar-bg);
|
|
border: 1px solid var(--toolbar-border);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 2px;
|
|
transition: background 0.3s ease, border-color 0.3s ease;
|
|
}
|
|
|
|
.mind-map .mm-toolbar-item {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--toolbar-text);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
transition: color 0.3s ease;
|
|
}
|
|
|
|
.mind-map .mm-toolbar-item.active {
|
|
background-color: var(--toolbar-active);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="mind-map">
|
|
<svg id="markmap"></svg>
|
|
<div id="toolbar"></div>
|
|
</div>
|
|
<script>
|
|
const { Transformer, Markmap, Toolbar } = window.markmap;
|
|
const transformer = new Transformer();
|
|
let mm = null;
|
|
|
|
// ⚠️ 下面这些函数必须先于首次渲染定义好:总结正文是外部内容,transform/create
|
|
// 抛异常时若函数还没挂上 window,Dart 侧后续的 updateMarkmap/updateTheme
|
|
// 全都调不到,整张图就再也回不来了。
|
|
window.updateMarkmap = async function(value) {
|
|
if (!mm) {
|
|
window.renderMarkmap(value);
|
|
return;
|
|
}
|
|
const { root } = transformer.transform(value || '');
|
|
await mm.setData(root);
|
|
await mm.fit();
|
|
}
|
|
|
|
window.renderMarkmap = function(value) {
|
|
const { root } = transformer.transform(value || '');
|
|
mm = Markmap.create('#markmap', {
|
|
maxWidth: 300,
|
|
initialExpandLevel: 3,
|
|
}, root);
|
|
const toolbar = Toolbar.create(mm);
|
|
toolbar.showBrand = false;
|
|
// 只保留放大、缩小、适配窗口、递归展开;移除自带的 dark 按钮
|
|
toolbar.setItems(['zoomIn', 'zoomOut', 'fit', 'recurse']);
|
|
toolbar.render();
|
|
const el = toolbar.el;
|
|
const toolbarElement = document.getElementById('toolbar');
|
|
toolbarElement.innerHTML = '';
|
|
toolbarElement.append(el);
|
|
}
|
|
|
|
// 动态切换主题颜色
|
|
window.updateTheme = function(isDark) {
|
|
const r = document.documentElement;
|
|
if (isDark) {
|
|
// 确保 markmap 内部暗色样式与 App 暗色主题一致
|
|
r.classList.add('markmap-dark');
|
|
r.style.setProperty('--bg-color', '#121212');
|
|
r.style.setProperty('--text-color', '#FFFFFF');
|
|
r.style.setProperty('--toolbar-bg', '#2D2D2D');
|
|
r.style.setProperty('--toolbar-border', '#404040');
|
|
r.style.setProperty('--toolbar-text', '#FFFFFF');
|
|
r.style.setProperty('--toolbar-active', '#404040');
|
|
} else {
|
|
// 还原为浅色主题
|
|
r.classList.remove('markmap-dark');
|
|
r.style.setProperty('--bg-color', '#FFFFFF');
|
|
r.style.setProperty('--text-color', '#000000');
|
|
r.style.setProperty('--toolbar-bg', '#FFFFFF');
|
|
r.style.setProperty('--toolbar-border', '#d0d5dd');
|
|
r.style.setProperty('--toolbar-text', '#101828');
|
|
r.style.setProperty('--toolbar-active', '#d4d4d8');
|
|
}
|
|
// 保存当前主题用于截图
|
|
window._isDarkTheme = isDark;
|
|
}
|
|
|
|
window.captureImage = async function() {
|
|
if (!mm) return;
|
|
const markmapElement = document.getElementById('markmap');
|
|
markmapElement.style.height = `${_width}px`;
|
|
await mm.fit();
|
|
const bgColor = window._isDarkTheme ? '#121212' : '#FFFFFF';
|
|
const options = {
|
|
scale: 2,
|
|
backgroundColor: bgColor,
|
|
encoderOptions: 1,
|
|
};
|
|
const uri = await svgAsPngUri(markmapElement, options);
|
|
markmapElement.style.height = '100%';
|
|
mm.fit();
|
|
if (window.ImageChannel) {
|
|
window.ImageChannel.postMessage(uri);
|
|
}
|
|
}
|
|
|
|
try {
|
|
window.renderMarkmap($summaryJs);
|
|
} catch (e) {
|
|
// 宁可看到一行错,也不要一片空白——空白时没人知道是没数据还是脚本挂了
|
|
document.getElementById('markmap').outerHTML =
|
|
'<div style="padding:16px;color:var(--text-color);font-size:14px;">'
|
|
+ 'mindmap render failed: ' + e + '</div>';
|
|
}
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|
|
''';
|
|
webViewController = WebViewController()
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..setNavigationDelegate(
|
|
NavigationDelegate(
|
|
onPageFinished: (String url) {
|
|
isWebViewReady = true;
|
|
// 页面加载完成后立即应用当前主题
|
|
_applyCurrentTheme();
|
|
},
|
|
),
|
|
)
|
|
..addJavaScriptChannel(
|
|
'ImageChannel',
|
|
onMessageReceived: _messageReceived,
|
|
)
|
|
..setBackgroundColor(Colors.transparent)
|
|
..loadHtmlString(html);
|
|
_summaryNumberListener =
|
|
ever(_meetingDetailsController.summaryNumberModifications, (value) {
|
|
webViewController.runJavaScript(
|
|
'updateMarkmap(${_jsString(_meetingDetailsController.meetingDetails.value.summary)})');
|
|
});
|
|
}
|
|
|
|
/// 把任意文本编码成一个合法的 JS 字符串字面量。
|
|
///
|
|
/// ⚠️ 总结正文是大模型产出的 Markdown,里头出现反引号(行内代码、甚至整段
|
|
/// ```markdown 包裹)是常事。原先直接拼进 JS 模板串 `${summary}`,一个反引号
|
|
/// 就让整个 <script> 块变成语法错误——**块内所有函数都不会被定义**,页面一片
|
|
/// 空白且不报任何错;`${` 则会被当成插值去求值,抛 ReferenceError。
|
|
/// 另外 `</` 必须转义,否则正文里的 `</script>` 会提前截断脚本块。
|
|
static String _jsString(String value) =>
|
|
jsonEncode(value).replaceAll('</', r'<\/');
|
|
|
|
/// 应用当前主题到 WebView
|
|
void _applyCurrentTheme() {
|
|
if (!isWebViewReady) return;
|
|
final isDark = Get.isDarkMode;
|
|
webViewController.runJavaScript('updateTheme($isDark)');
|
|
}
|
|
|
|
/// 供外部调用的主题更新方法
|
|
void updateTheme() {
|
|
_applyCurrentTheme();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_summaryNumberListener.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
void _messageReceived(message) async {
|
|
final String dataUrl = message.message;
|
|
final String base64Str = dataUrl.split(',').last;
|
|
final Uint8List bytes = base64.decode(base64Str);
|
|
if (imageType == 1) {
|
|
// 保存图片
|
|
final result = await ImageGallerySaverPlus.saveImage(
|
|
bytes,
|
|
quality: 100,
|
|
);
|
|
if (result['isSuccess']) {
|
|
EasyLoading.showToast('saveSuccess'.tr);
|
|
}
|
|
} else {
|
|
// 分享图片
|
|
final String fileName =
|
|
'${_meetingDetailsController.meetingData.value.title}_mindmap.png';
|
|
final tempDir = await getTemporaryDirectory();
|
|
final File file = File('${tempDir.path}/$fileName');
|
|
await file.writeAsBytes(bytes);
|
|
final result = await SharePlus.instance.share(ShareParams(
|
|
text: 'shareMindMap'.tr, //对应中文:分享思维导图
|
|
files: [XFile(file.path)],
|
|
));
|
|
if (result.status == ShareResultStatus.success) {
|
|
EasyLoading.showToast('shareSuccess'.tr); //对应中文:分享成功
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|