Showing
8 changed files
with
640 additions
and
228 deletions
@@ -4,7 +4,9 @@ import 'package:desktop_drop/desktop_drop.dart'; | @@ -4,7 +4,9 @@ import 'package:desktop_drop/desktop_drop.dart'; | ||
4 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
5 | import 'package:gpt_markdown/gpt_markdown.dart'; | 5 | import 'package:gpt_markdown/gpt_markdown.dart'; |
6 | import 'package:flutter_math_fork/flutter_math.dart'; | 6 | import 'package:flutter_math_fork/flutter_math.dart'; |
7 | +import 'package:gpt_markdown/theme.dart'; | ||
7 | import 'package:watcher/watcher.dart'; | 8 | import 'package:watcher/watcher.dart'; |
9 | +import 'selectable_adapter.dart'; | ||
8 | 10 | ||
9 | void main() { | 11 | void main() { |
10 | runApp(const MyApp()); | 12 | runApp(const MyApp()); |
@@ -31,10 +33,14 @@ class _MyAppState extends State<MyApp> { | @@ -31,10 +33,14 @@ class _MyAppState extends State<MyApp> { | ||
31 | colorSchemeSeed: Colors.blue, | 33 | colorSchemeSeed: Colors.blue, |
32 | ), | 34 | ), |
33 | darkTheme: ThemeData( | 35 | darkTheme: ThemeData( |
34 | - useMaterial3: true, | ||
35 | - brightness: Brightness.dark, | ||
36 | - colorSchemeSeed: Colors.blue, | ||
37 | - ), | 36 | + useMaterial3: true, |
37 | + brightness: Brightness.dark, | ||
38 | + colorSchemeSeed: Colors.blue, | ||
39 | + extensions: [ | ||
40 | + GptMarkdownThemeData( | ||
41 | + highlightColor: Colors.red, | ||
42 | + ), | ||
43 | + ]), | ||
38 | home: MyHomePage( | 44 | home: MyHomePage( |
39 | title: 'GptMarkdown', | 45 | title: 'GptMarkdown', |
40 | onPressed: () { | 46 | onPressed: () { |
@@ -147,241 +153,254 @@ Markdown and LaTeX can be powerful tools for formatting text and mathematical ex | @@ -147,241 +153,254 @@ Markdown and LaTeX can be powerful tools for formatting text and mathematical ex | ||
147 | 153 | ||
148 | @override | 154 | @override |
149 | Widget build(BuildContext context) { | 155 | Widget build(BuildContext context) { |
150 | - return Scaffold( | ||
151 | - appBar: AppBar( | ||
152 | - title: Text(widget.title), | ||
153 | - actions: [ | ||
154 | - IconButton( | ||
155 | - onPressed: () { | ||
156 | - setState(() { | ||
157 | - selectable = !selectable; | ||
158 | - }); | ||
159 | - }, | ||
160 | - icon: Icon( | ||
161 | - Icons.select_all_outlined, | ||
162 | - color: selectable | ||
163 | - ? Theme.of(context).colorScheme.onSurfaceVariant | ||
164 | - : Theme.of(context).colorScheme.onSurface.withOpacity(0.38), | ||
165 | - ), | ||
166 | - ), | ||
167 | - IconButton( | ||
168 | - onPressed: () { | ||
169 | - setState(() { | ||
170 | - _direction = TextDirection.values[(_direction.index + 1) % 2]; | ||
171 | - }); | ||
172 | - }, | ||
173 | - icon: const [Text("LTR"), Text("RTL")][_direction.index], | ||
174 | - ), | ||
175 | - IconButton( | ||
176 | - onPressed: widget.onPressed, | ||
177 | - icon: const Icon(Icons.sunny), | ||
178 | - ), | ||
179 | - IconButton( | ||
180 | - onPressed: () => setState(() { | ||
181 | - writingMod = !writingMod; | ||
182 | - }), | ||
183 | - icon: | ||
184 | - Icon(writingMod ? Icons.arrow_drop_down : Icons.arrow_drop_up), | ||
185 | - ), | ||
186 | - ], | 156 | + return GptMarkdownTheme( |
157 | + gptThemeData: GptMarkdownTheme.of(context).copyWith( | ||
158 | + highlightColor: Colors.purple, | ||
187 | ), | 159 | ), |
188 | - body: DropTarget( | ||
189 | - onDragDone: (details) { | ||
190 | - var files = details.files; | ||
191 | - if (files.length != 1) { | ||
192 | - return; | ||
193 | - } | ||
194 | - var file = files[0]; | ||
195 | - String path = file.path; | ||
196 | - this.file = File(path); | ||
197 | - load(); | ||
198 | - }, | ||
199 | - child: Stack( | ||
200 | - children: [ | ||
201 | - Column( | ||
202 | - children: [ | ||
203 | - Expanded( | ||
204 | - child: ListView( | ||
205 | - children: [ | ||
206 | - AnimatedBuilder( | ||
207 | - animation: _controller, | ||
208 | - builder: (context, _) { | ||
209 | - return Container( | ||
210 | - padding: const EdgeInsets.all(8), | ||
211 | - decoration: BoxDecoration( | ||
212 | - border: Border.all( | ||
213 | - width: 1, | ||
214 | - color: Theme.of(context).colorScheme.outline), | ||
215 | - ), | ||
216 | - child: Theme( | ||
217 | - data: Theme.of(context), | ||
218 | - // .copyWith( | ||
219 | - // textTheme: const TextTheme( | ||
220 | - // // For H1. | ||
221 | - // headlineLarge: TextStyle(fontSize: 55), | ||
222 | - // // For H2. | ||
223 | - // headlineMedium: TextStyle(fontSize: 45), | ||
224 | - // // For H3. | ||
225 | - // headlineSmall: TextStyle(fontSize: 35), | ||
226 | - // // For H4. | ||
227 | - // titleLarge: TextStyle(fontSize: 25), | ||
228 | - // // For H5. | ||
229 | - // titleMedium: TextStyle(fontSize: 15), | ||
230 | - // // For H6. | ||
231 | - // titleSmall: TextStyle(fontSize: 10), | ||
232 | - // ), | ||
233 | - // ), | ||
234 | - child: Builder( | ||
235 | - builder: (context) { | ||
236 | - Widget child = TexMarkdown( | ||
237 | - _controller.text, | ||
238 | - textDirection: _direction, | ||
239 | - onLinkTab: (url, title) { | ||
240 | - debugPrint(url); | ||
241 | - debugPrint(title); | ||
242 | - }, | ||
243 | - textAlign: TextAlign.justify, | ||
244 | - textScaler: const TextScaler.linear(1), | ||
245 | - style: const TextStyle( | ||
246 | - fontSize: 15, | ||
247 | - ), | ||
248 | - latexWorkaround: (tex) { | ||
249 | - List<String> stack = []; | ||
250 | - tex = tex.splitMapJoin( | ||
251 | - RegExp(r"\\text\{|\{|\}|\_"), | ||
252 | - onMatch: (p) { | ||
253 | - String input = p[0] ?? ""; | ||
254 | - if (input == r"\text{") { | ||
255 | - stack.add(input); | ||
256 | - } | ||
257 | - if (stack.isNotEmpty) { | ||
258 | - if (input == r"{") { | 160 | + child: Scaffold( |
161 | + appBar: AppBar( | ||
162 | + title: Text(widget.title), | ||
163 | + actions: [ | ||
164 | + IconButton( | ||
165 | + onPressed: () { | ||
166 | + setState(() { | ||
167 | + selectable = !selectable; | ||
168 | + }); | ||
169 | + }, | ||
170 | + icon: Icon( | ||
171 | + Icons.select_all_outlined, | ||
172 | + color: selectable | ||
173 | + ? Theme.of(context).colorScheme.onSurfaceVariant | ||
174 | + : Theme.of(context).colorScheme.onSurface.withOpacity(0.38), | ||
175 | + ), | ||
176 | + ), | ||
177 | + IconButton( | ||
178 | + onPressed: () { | ||
179 | + setState(() { | ||
180 | + _direction = TextDirection.values[(_direction.index + 1) % 2]; | ||
181 | + }); | ||
182 | + }, | ||
183 | + icon: const [Text("LTR"), Text("RTL")][_direction.index], | ||
184 | + ), | ||
185 | + IconButton( | ||
186 | + onPressed: widget.onPressed, | ||
187 | + icon: const Icon(Icons.sunny), | ||
188 | + ), | ||
189 | + IconButton( | ||
190 | + onPressed: () => setState(() { | ||
191 | + writingMod = !writingMod; | ||
192 | + }), | ||
193 | + icon: Icon( | ||
194 | + writingMod ? Icons.arrow_drop_down : Icons.arrow_drop_up), | ||
195 | + ), | ||
196 | + ], | ||
197 | + ), | ||
198 | + body: DropTarget( | ||
199 | + onDragDone: (details) { | ||
200 | + var files = details.files; | ||
201 | + if (files.length != 1) { | ||
202 | + return; | ||
203 | + } | ||
204 | + var file = files[0]; | ||
205 | + String path = file.path; | ||
206 | + this.file = File(path); | ||
207 | + load(); | ||
208 | + }, | ||
209 | + child: Stack( | ||
210 | + children: [ | ||
211 | + Column( | ||
212 | + children: [ | ||
213 | + Expanded( | ||
214 | + child: ListView( | ||
215 | + children: [ | ||
216 | + AnimatedBuilder( | ||
217 | + animation: _controller, | ||
218 | + builder: (context, _) { | ||
219 | + return Container( | ||
220 | + padding: const EdgeInsets.all(8), | ||
221 | + decoration: BoxDecoration( | ||
222 | + border: Border.all( | ||
223 | + width: 1, | ||
224 | + color: | ||
225 | + Theme.of(context).colorScheme.outline), | ||
226 | + ), | ||
227 | + child: Theme( | ||
228 | + data: Theme.of(context), | ||
229 | + // .copyWith( | ||
230 | + // textTheme: const TextTheme( | ||
231 | + // // For H1. | ||
232 | + // headlineLarge: TextStyle(fontSize: 55), | ||
233 | + // // For H2. | ||
234 | + // headlineMedium: TextStyle(fontSize: 45), | ||
235 | + // // For H3. | ||
236 | + // headlineSmall: TextStyle(fontSize: 35), | ||
237 | + // // For H4. | ||
238 | + // titleLarge: TextStyle(fontSize: 25), | ||
239 | + // // For H5. | ||
240 | + // titleMedium: TextStyle(fontSize: 15), | ||
241 | + // // For H6. | ||
242 | + // titleSmall: TextStyle(fontSize: 10), | ||
243 | + // ), | ||
244 | + // ), | ||
245 | + child: Builder( | ||
246 | + builder: (context) { | ||
247 | + Widget child = TexMarkdown( | ||
248 | + _controller.text, | ||
249 | + textDirection: _direction, | ||
250 | + onLinkTab: (url, title) { | ||
251 | + debugPrint(url); | ||
252 | + debugPrint(title); | ||
253 | + }, | ||
254 | + textAlign: TextAlign.justify, | ||
255 | + textScaler: const TextScaler.linear(1), | ||
256 | + style: const TextStyle( | ||
257 | + fontSize: 15, | ||
258 | + ), | ||
259 | + latexWorkaround: (tex) { | ||
260 | + List<String> stack = []; | ||
261 | + tex = tex.splitMapJoin( | ||
262 | + RegExp(r"\\text\{|\{|\}|\_"), | ||
263 | + onMatch: (p) { | ||
264 | + String input = p[0] ?? ""; | ||
265 | + if (input == r"\text{") { | ||
259 | stack.add(input); | 266 | stack.add(input); |
260 | } | 267 | } |
261 | - if (input == r"}") { | ||
262 | - stack.removeLast(); | 268 | + if (stack.isNotEmpty) { |
269 | + if (input == r"{") { | ||
270 | + stack.add(input); | ||
271 | + } | ||
272 | + if (input == r"}") { | ||
273 | + stack.removeLast(); | ||
274 | + } | ||
275 | + if (input == r"_") { | ||
276 | + return r"\_"; | ||
277 | + } | ||
263 | } | 278 | } |
264 | - if (input == r"_") { | ||
265 | - return r"\_"; | ||
266 | - } | ||
267 | - } | ||
268 | - return input; | ||
269 | - }, | ||
270 | - ); | ||
271 | - return tex.replaceAllMapped( | ||
272 | - RegExp(r"align\*"), | ||
273 | - (match) => "aligned"); | ||
274 | - }, | ||
275 | - latexBuilder: | ||
276 | - (contex, tex, textStyle, inline) { | ||
277 | - if (tex.contains(r"\begin{tabular}")) { | ||
278 | - // return table. | ||
279 | - String tableString = "|${(RegExp( | ||
280 | - r"^\\begin\{tabular\}\{.*?\}(.*?)\\end\{tabular\}$", | ||
281 | - multiLine: true, | ||
282 | - dotAll: true, | ||
283 | - ).firstMatch(tex)?[1] ?? "").trim()}|"; | ||
284 | - tableString = tableString | ||
285 | - .replaceAll(r"\\", "|\n|") | ||
286 | - .replaceAll(r"\hline", "") | ||
287 | - .replaceAll( | ||
288 | - RegExp(r"(?<!\\)&"), "|"); | ||
289 | - var tableStringList = tableString | ||
290 | - .split("\n") | ||
291 | - ..insert(1, "|---|"); | ||
292 | - tableString = | ||
293 | - tableStringList.join("\n"); | ||
294 | - return TexMarkdown(tableString); | ||
295 | - } | ||
296 | - var controller = ScrollController(); | ||
297 | - Widget child = Math.tex( | ||
298 | - tex, | ||
299 | - textStyle: textStyle, | ||
300 | - ); | ||
301 | - if (!inline) { | ||
302 | - child = Padding( | ||
303 | - padding: const EdgeInsets.all(0.0), | ||
304 | - child: Material( | ||
305 | - color: Theme.of(context) | ||
306 | - .colorScheme | ||
307 | - .onInverseSurface, | ||
308 | - child: Padding( | ||
309 | - padding: | ||
310 | - const EdgeInsets.all(8.0), | ||
311 | - child: Scrollbar( | ||
312 | - controller: controller, | ||
313 | - child: SingleChildScrollView( | 279 | + return input; |
280 | + }, | ||
281 | + ); | ||
282 | + return tex.replaceAllMapped( | ||
283 | + RegExp(r"align\*"), | ||
284 | + (match) => "aligned"); | ||
285 | + }, | ||
286 | + latexBuilder: | ||
287 | + (contex, tex, textStyle, inline) { | ||
288 | + if (tex.contains(r"\begin{tabular}")) { | ||
289 | + // return table. | ||
290 | + String tableString = "|${(RegExp( | ||
291 | + r"^\\begin\{tabular\}\{.*?\}(.*?)\\end\{tabular\}$", | ||
292 | + multiLine: true, | ||
293 | + dotAll: true, | ||
294 | + ).firstMatch(tex)?[1] ?? "").trim()}|"; | ||
295 | + tableString = tableString | ||
296 | + .replaceAll(r"\\", "|\n|") | ||
297 | + .replaceAll(r"\hline", "") | ||
298 | + .replaceAll( | ||
299 | + RegExp(r"(?<!\\)&"), "|"); | ||
300 | + var tableStringList = tableString | ||
301 | + .split("\n") | ||
302 | + ..insert(1, "|---|"); | ||
303 | + tableString = | ||
304 | + tableStringList.join("\n"); | ||
305 | + return TexMarkdown(tableString); | ||
306 | + } | ||
307 | + var controller = ScrollController(); | ||
308 | + Widget child = Math.tex( | ||
309 | + tex, | ||
310 | + textStyle: textStyle, | ||
311 | + ); | ||
312 | + if (!inline) { | ||
313 | + child = Padding( | ||
314 | + padding: const EdgeInsets.all(0.0), | ||
315 | + child: Material( | ||
316 | + color: Theme.of(context) | ||
317 | + .colorScheme | ||
318 | + .onInverseSurface, | ||
319 | + child: Padding( | ||
320 | + padding: | ||
321 | + const EdgeInsets.all(8.0), | ||
322 | + child: Scrollbar( | ||
314 | controller: controller, | 323 | controller: controller, |
315 | - scrollDirection: | ||
316 | - Axis.horizontal, | ||
317 | - child: Math.tex( | ||
318 | - tex, | ||
319 | - textStyle: textStyle, | 324 | + child: SingleChildScrollView( |
325 | + controller: controller, | ||
326 | + scrollDirection: | ||
327 | + Axis.horizontal, | ||
328 | + child: Math.tex( | ||
329 | + tex, | ||
330 | + textStyle: textStyle, | ||
331 | + ), | ||
320 | ), | 332 | ), |
321 | ), | 333 | ), |
322 | ), | 334 | ), |
323 | ), | 335 | ), |
336 | + ); | ||
337 | + } | ||
338 | + child = SelectableAdapter( | ||
339 | + selectedText: tex, | ||
340 | + child: Math.tex(tex), | ||
341 | + ); | ||
342 | + child = InkWell( | ||
343 | + onTap: () { | ||
344 | + debugPrint("Hello world"); | ||
345 | + }, | ||
346 | + child: child, | ||
347 | + ); | ||
348 | + return child; | ||
349 | + }, | ||
350 | + sourceTagBuilder: | ||
351 | + (buildContext, string, textStyle) { | ||
352 | + var value = int.tryParse(string); | ||
353 | + value ??= -1; | ||
354 | + value += 1; | ||
355 | + return SizedBox( | ||
356 | + height: 20, | ||
357 | + width: 20, | ||
358 | + child: Container( | ||
359 | + decoration: BoxDecoration( | ||
360 | + color: Colors.red, | ||
361 | + borderRadius: | ||
362 | + BorderRadius.circular(10), | ||
363 | + ), | ||
364 | + child: | ||
365 | + Center(child: Text("$value")), | ||
324 | ), | 366 | ), |
325 | ); | 367 | ); |
326 | - } | ||
327 | - child = InkWell( | ||
328 | - onTap: () { | ||
329 | - debugPrint("Hello world"); | ||
330 | - }, | 368 | + }, |
369 | + ); | ||
370 | + if (selectable) { | ||
371 | + child = SelectionArea( | ||
331 | child: child, | 372 | child: child, |
332 | ); | 373 | ); |
333 | - return child; | ||
334 | - }, | ||
335 | - sourceTagBuilder: | ||
336 | - (buildContext, string, textStyle) { | ||
337 | - var value = int.tryParse(string); | ||
338 | - value ??= -1; | ||
339 | - value += 1; | ||
340 | - return SizedBox( | ||
341 | - height: 20, | ||
342 | - width: 20, | ||
343 | - child: Container( | ||
344 | - decoration: BoxDecoration( | ||
345 | - color: Colors.red, | ||
346 | - borderRadius: | ||
347 | - BorderRadius.circular(10), | ||
348 | - ), | ||
349 | - child: Center(child: Text("$value")), | ||
350 | - ), | ||
351 | - ); | ||
352 | - }, | ||
353 | - ); | ||
354 | - if (selectable) { | ||
355 | - child = SelectionArea(child: child); | ||
356 | - } | ||
357 | - return child; | ||
358 | - }, | 374 | + } |
375 | + return child; | ||
376 | + }, | ||
377 | + ), | ||
378 | + // child: const Text("Hello"), | ||
359 | ), | 379 | ), |
360 | - // child: const Text("Hello"), | ||
361 | - ), | ||
362 | - ); | ||
363 | - }, | ||
364 | - ), | ||
365 | - ], | 380 | + ); |
381 | + }, | ||
382 | + ), | ||
383 | + ], | ||
384 | + ), | ||
366 | ), | 385 | ), |
367 | - ), | ||
368 | - if (writingMod) | ||
369 | - ConstrainedBox( | ||
370 | - constraints: const BoxConstraints(maxHeight: 200), | ||
371 | - child: Padding( | ||
372 | - padding: const EdgeInsets.all(8.0), | ||
373 | - child: TextField( | ||
374 | - decoration: const InputDecoration( | ||
375 | - border: OutlineInputBorder(), | ||
376 | - label: Text("Type here:")), | ||
377 | - maxLines: null, | ||
378 | - controller: _controller, | 386 | + if (writingMod) |
387 | + ConstrainedBox( | ||
388 | + constraints: const BoxConstraints(maxHeight: 200), | ||
389 | + child: Padding( | ||
390 | + padding: const EdgeInsets.all(8.0), | ||
391 | + child: TextField( | ||
392 | + decoration: const InputDecoration( | ||
393 | + border: OutlineInputBorder(), | ||
394 | + label: Text("Type here:")), | ||
395 | + maxLines: null, | ||
396 | + controller: _controller, | ||
397 | + ), | ||
379 | ), | 398 | ), |
380 | ), | 399 | ), |
381 | - ), | ||
382 | - ], | ||
383 | - ), | ||
384 | - ], | 400 | + ], |
401 | + ), | ||
402 | + ], | ||
403 | + ), | ||
385 | ), | 404 | ), |
386 | ), | 405 | ), |
387 | ); | 406 | ); |
1 | +import 'package:flutter/material.dart'; | ||
2 | +import 'package:flutter/rendering.dart'; | ||
3 | + | ||
4 | +class SelectableAdapter extends StatelessWidget { | ||
5 | + const SelectableAdapter( | ||
6 | + {super.key, required this.selectedText, required this.child}); | ||
7 | + | ||
8 | + final Widget child; | ||
9 | + final String selectedText; | ||
10 | + | ||
11 | + @override | ||
12 | + Widget build(BuildContext context) { | ||
13 | + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); | ||
14 | + if (registrar == null) { | ||
15 | + return child; | ||
16 | + } | ||
17 | + return MouseRegion( | ||
18 | + cursor: SystemMouseCursors.text, | ||
19 | + child: _SelectableAdapter( | ||
20 | + registrar: registrar, | ||
21 | + selectedText: selectedText, | ||
22 | + child: child, | ||
23 | + ), | ||
24 | + ); | ||
25 | + } | ||
26 | +} | ||
27 | + | ||
28 | +class _SelectableAdapter extends SingleChildRenderObjectWidget { | ||
29 | + const _SelectableAdapter({ | ||
30 | + required this.registrar, | ||
31 | + required Widget child, | ||
32 | + required this.selectedText, | ||
33 | + }) : super(child: child); | ||
34 | + | ||
35 | + final SelectionRegistrar registrar; | ||
36 | + final String selectedText; | ||
37 | + | ||
38 | + @override | ||
39 | + _RenderSelectableAdapter createRenderObject(BuildContext context) { | ||
40 | + return _RenderSelectableAdapter( | ||
41 | + DefaultSelectionStyle.of(context).selectionColor!, | ||
42 | + selectedText, | ||
43 | + registrar, | ||
44 | + ); | ||
45 | + } | ||
46 | + | ||
47 | + @override | ||
48 | + void updateRenderObject( | ||
49 | + BuildContext context, _RenderSelectableAdapter renderObject) { | ||
50 | + renderObject | ||
51 | + ..selectionColor = DefaultSelectionStyle.of(context).selectionColor! | ||
52 | + ..registrar = registrar; | ||
53 | + } | ||
54 | +} | ||
55 | + | ||
56 | +class _RenderSelectableAdapter extends RenderProxyBox | ||
57 | + with Selectable, SelectionRegistrant { | ||
58 | + String selectionText; | ||
59 | + _RenderSelectableAdapter( | ||
60 | + Color selectionColor, | ||
61 | + this.selectionText, | ||
62 | + SelectionRegistrar registrar, | ||
63 | + ) : _selectionColor = selectionColor, | ||
64 | + _geometry = ValueNotifier<SelectionGeometry>(_noSelection) { | ||
65 | + this.registrar = registrar; | ||
66 | + _geometry.addListener(markNeedsPaint); | ||
67 | + } | ||
68 | + | ||
69 | + static const SelectionGeometry _noSelection = | ||
70 | + SelectionGeometry(status: SelectionStatus.none, hasContent: true); | ||
71 | + final ValueNotifier<SelectionGeometry> _geometry; | ||
72 | + | ||
73 | + Color get selectionColor => _selectionColor; | ||
74 | + late Color _selectionColor; | ||
75 | + set selectionColor(Color value) { | ||
76 | + if (_selectionColor == value) { | ||
77 | + return; | ||
78 | + } | ||
79 | + _selectionColor = value; | ||
80 | + markNeedsPaint(); | ||
81 | + } | ||
82 | + | ||
83 | + // ValueListenable APIs | ||
84 | + | ||
85 | + @override | ||
86 | + void addListener(VoidCallback listener) => _geometry.addListener(listener); | ||
87 | + | ||
88 | + @override | ||
89 | + void removeListener(VoidCallback listener) => | ||
90 | + _geometry.removeListener(listener); | ||
91 | + | ||
92 | + @override | ||
93 | + SelectionGeometry get value => _geometry.value; | ||
94 | + | ||
95 | + // Selectable APIs. | ||
96 | + | ||
97 | + @override | ||
98 | + List<Rect> get boundingBoxes => <Rect>[paintBounds]; | ||
99 | + | ||
100 | + // Adjust this value to enlarge or shrink the selection highlight. | ||
101 | + static const double _padding = 0.0; | ||
102 | + Rect _getSelectionHighlightRect() { | ||
103 | + return Rect.fromLTWH(0 - _padding, 0 - _padding, size.width + _padding * 2, | ||
104 | + size.height + _padding * 2); | ||
105 | + } | ||
106 | + | ||
107 | + Offset? _start; | ||
108 | + Offset? _end; | ||
109 | + void _updateGeometry() { | ||
110 | + if (_start == null || _end == null) { | ||
111 | + _geometry.value = _noSelection; | ||
112 | + return; | ||
113 | + } | ||
114 | + final Rect renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); | ||
115 | + final Rect selectionRect = Rect.fromPoints(_start!, _end!); | ||
116 | + if (renderObjectRect.intersect(selectionRect).isEmpty) { | ||
117 | + _geometry.value = _noSelection; | ||
118 | + } else { | ||
119 | + final Rect selectionRect = _getSelectionHighlightRect(); | ||
120 | + final SelectionPoint firstSelectionPoint = SelectionPoint( | ||
121 | + localPosition: selectionRect.bottomLeft, | ||
122 | + lineHeight: selectionRect.size.height, | ||
123 | + handleType: TextSelectionHandleType.left, | ||
124 | + ); | ||
125 | + final SelectionPoint secondSelectionPoint = SelectionPoint( | ||
126 | + localPosition: selectionRect.bottomRight, | ||
127 | + lineHeight: selectionRect.size.height, | ||
128 | + handleType: TextSelectionHandleType.right, | ||
129 | + ); | ||
130 | + final bool isReversed; | ||
131 | + if (_start!.dy > _end!.dy) { | ||
132 | + isReversed = true; | ||
133 | + } else if (_start!.dy < _end!.dy) { | ||
134 | + isReversed = false; | ||
135 | + } else { | ||
136 | + isReversed = _start!.dx > _end!.dx; | ||
137 | + } | ||
138 | + _geometry.value = SelectionGeometry( | ||
139 | + status: SelectionStatus.uncollapsed, | ||
140 | + hasContent: true, | ||
141 | + startSelectionPoint: | ||
142 | + isReversed ? secondSelectionPoint : firstSelectionPoint, | ||
143 | + endSelectionPoint: | ||
144 | + isReversed ? firstSelectionPoint : secondSelectionPoint, | ||
145 | + selectionRects: <Rect>[selectionRect], | ||
146 | + ); | ||
147 | + } | ||
148 | + } | ||
149 | + | ||
150 | + @override | ||
151 | + SelectionResult dispatchSelectionEvent(SelectionEvent event) { | ||
152 | + SelectionResult result = SelectionResult.none; | ||
153 | + switch (event.type) { | ||
154 | + case SelectionEventType.startEdgeUpdate: | ||
155 | + case SelectionEventType.endEdgeUpdate: | ||
156 | + final Rect renderObjectRect = | ||
157 | + Rect.fromLTWH(0, 0, size.width, size.height); | ||
158 | + // Normalize offset in case it is out side of the rect. | ||
159 | + final Offset point = | ||
160 | + globalToLocal((event as SelectionEdgeUpdateEvent).globalPosition); | ||
161 | + final Offset adjustedPoint = | ||
162 | + SelectionUtils.adjustDragOffset(renderObjectRect, point); | ||
163 | + if (event.type == SelectionEventType.startEdgeUpdate) { | ||
164 | + _start = adjustedPoint; | ||
165 | + } else { | ||
166 | + _end = adjustedPoint; | ||
167 | + } | ||
168 | + result = SelectionUtils.getResultBasedOnRect(renderObjectRect, point); | ||
169 | + case SelectionEventType.clear: | ||
170 | + _start = _end = null; | ||
171 | + case SelectionEventType.selectAll: | ||
172 | + case SelectionEventType.selectWord: | ||
173 | + case SelectionEventType.selectParagraph: | ||
174 | + _start = Offset.zero; | ||
175 | + _end = Offset.infinite; | ||
176 | + case SelectionEventType.granularlyExtendSelection: | ||
177 | + result = SelectionResult.end; | ||
178 | + final GranularlyExtendSelectionEvent extendSelectionEvent = | ||
179 | + event as GranularlyExtendSelectionEvent; | ||
180 | + // Initialize the offset it there is no ongoing selection. | ||
181 | + if (_start == null || _end == null) { | ||
182 | + if (extendSelectionEvent.forward) { | ||
183 | + _start = _end = Offset.zero; | ||
184 | + } else { | ||
185 | + _start = _end = Offset.infinite; | ||
186 | + } | ||
187 | + } | ||
188 | + // Move the corresponding selection edge. | ||
189 | + final Offset newOffset = | ||
190 | + extendSelectionEvent.forward ? Offset.infinite : Offset.zero; | ||
191 | + if (extendSelectionEvent.isEnd) { | ||
192 | + if (newOffset == _end) { | ||
193 | + result = extendSelectionEvent.forward | ||
194 | + ? SelectionResult.next | ||
195 | + : SelectionResult.previous; | ||
196 | + } | ||
197 | + _end = newOffset; | ||
198 | + } else { | ||
199 | + if (newOffset == _start) { | ||
200 | + result = extendSelectionEvent.forward | ||
201 | + ? SelectionResult.next | ||
202 | + : SelectionResult.previous; | ||
203 | + } | ||
204 | + _start = newOffset; | ||
205 | + } | ||
206 | + case SelectionEventType.directionallyExtendSelection: | ||
207 | + result = SelectionResult.end; | ||
208 | + final DirectionallyExtendSelectionEvent extendSelectionEvent = | ||
209 | + event as DirectionallyExtendSelectionEvent; | ||
210 | + // Convert to local coordinates. | ||
211 | + final double horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx; | ||
212 | + final Offset newOffset; | ||
213 | + final bool forward; | ||
214 | + switch (extendSelectionEvent.direction) { | ||
215 | + case SelectionExtendDirection.backward: | ||
216 | + case SelectionExtendDirection.previousLine: | ||
217 | + forward = false; | ||
218 | + // Initialize the offset it there is no ongoing selection. | ||
219 | + if (_start == null || _end == null) { | ||
220 | + _start = _end = Offset.infinite; | ||
221 | + } | ||
222 | + // Move the corresponding selection edge. | ||
223 | + if (extendSelectionEvent.direction == | ||
224 | + SelectionExtendDirection.previousLine || | ||
225 | + horizontalBaseLine < 0) { | ||
226 | + newOffset = Offset.zero; | ||
227 | + } else { | ||
228 | + newOffset = Offset.infinite; | ||
229 | + } | ||
230 | + case SelectionExtendDirection.nextLine: | ||
231 | + case SelectionExtendDirection.forward: | ||
232 | + forward = true; | ||
233 | + // Initialize the offset it there is no ongoing selection. | ||
234 | + if (_start == null || _end == null) { | ||
235 | + _start = _end = Offset.zero; | ||
236 | + } | ||
237 | + // Move the corresponding selection edge. | ||
238 | + if (extendSelectionEvent.direction == | ||
239 | + SelectionExtendDirection.nextLine || | ||
240 | + horizontalBaseLine > size.width) { | ||
241 | + newOffset = Offset.infinite; | ||
242 | + } else { | ||
243 | + newOffset = Offset.zero; | ||
244 | + } | ||
245 | + } | ||
246 | + if (extendSelectionEvent.isEnd) { | ||
247 | + if (newOffset == _end) { | ||
248 | + result = forward ? SelectionResult.next : SelectionResult.previous; | ||
249 | + } | ||
250 | + _end = newOffset; | ||
251 | + } else { | ||
252 | + if (newOffset == _start) { | ||
253 | + result = forward ? SelectionResult.next : SelectionResult.previous; | ||
254 | + } | ||
255 | + _start = newOffset; | ||
256 | + } | ||
257 | + } | ||
258 | + _updateGeometry(); | ||
259 | + return result; | ||
260 | + } | ||
261 | + | ||
262 | + // This method is called when users want to copy selected content in this | ||
263 | + // widget into clipboard. | ||
264 | + @override | ||
265 | + SelectedContent? getSelectedContent() { | ||
266 | + return value.hasSelection | ||
267 | + ? SelectedContent(plainText: selectionText) | ||
268 | + : null; | ||
269 | + } | ||
270 | + | ||
271 | + LayerLink? _startHandle; | ||
272 | + LayerLink? _endHandle; | ||
273 | + | ||
274 | + @override | ||
275 | + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { | ||
276 | + if (_startHandle == startHandle && _endHandle == endHandle) { | ||
277 | + return; | ||
278 | + } | ||
279 | + _startHandle = startHandle; | ||
280 | + _endHandle = endHandle; | ||
281 | + markNeedsPaint(); | ||
282 | + } | ||
283 | + | ||
284 | + @override | ||
285 | + void paint(PaintingContext context, Offset offset) { | ||
286 | + super.paint(context, offset); | ||
287 | + if (!_geometry.value.hasSelection) { | ||
288 | + return; | ||
289 | + } | ||
290 | + // Draw the selection highlight. | ||
291 | + final Paint selectionPaint = Paint() | ||
292 | + ..style = PaintingStyle.fill | ||
293 | + ..color = _selectionColor; | ||
294 | + context.canvas | ||
295 | + .drawRect(_getSelectionHighlightRect().shift(offset), selectionPaint); | ||
296 | + | ||
297 | + // Push the layer links if any. | ||
298 | + if (_startHandle != null) { | ||
299 | + context.pushLayer( | ||
300 | + LeaderLayer( | ||
301 | + link: _startHandle!, | ||
302 | + offset: offset + value.startSelectionPoint!.localPosition, | ||
303 | + ), | ||
304 | + (PaintingContext context, Offset offset) {}, | ||
305 | + Offset.zero, | ||
306 | + ); | ||
307 | + } | ||
308 | + if (_endHandle != null) { | ||
309 | + context.pushLayer( | ||
310 | + LeaderLayer( | ||
311 | + link: _endHandle!, | ||
312 | + offset: offset + value.endSelectionPoint!.localPosition, | ||
313 | + ), | ||
314 | + (PaintingContext context, Offset offset) {}, | ||
315 | + Offset.zero, | ||
316 | + ); | ||
317 | + } | ||
318 | + } | ||
319 | + | ||
320 | + @override | ||
321 | + void dispose() { | ||
322 | + _geometry.dispose(); | ||
323 | + super.dispose(); | ||
324 | + } | ||
325 | +} |
@@ -126,7 +126,7 @@ packages: | @@ -126,7 +126,7 @@ packages: | ||
126 | path: ".." | 126 | path: ".." |
127 | relative: true | 127 | relative: true |
128 | source: path | 128 | source: path |
129 | - version: "0.1.10" | 129 | + version: "0.1.11" |
130 | http: | 130 | http: |
131 | dependency: transitive | 131 | dependency: transitive |
132 | description: | 132 | description: |
@@ -373,5 +373,5 @@ packages: | @@ -373,5 +373,5 @@ packages: | ||
373 | source: hosted | 373 | source: hosted |
374 | version: "6.5.0" | 374 | version: "6.5.0" |
375 | sdks: | 375 | sdks: |
376 | - dart: ">=3.3.0 <4.0.0" | 376 | + dart: ">=3.5.0 <4.0.0" |
377 | flutter: ">=3.18.0-18.0.pre.54" | 377 | flutter: ">=3.18.0-18.0.pre.54" |
@@ -5,7 +5,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | @@ -5,7 +5,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||
5 | version: 1.0.0+1 | 5 | version: 1.0.0+1 |
6 | 6 | ||
7 | environment: | 7 | environment: |
8 | - sdk: '>=2.18.6 <4.0.0' | 8 | + sdk: '>=3.5.0 <4.0.0' |
9 | dependencies: | 9 | dependencies: |
10 | flutter: | 10 | flutter: |
11 | sdk: flutter | 11 | sdk: flutter |
@@ -7,6 +7,7 @@ import 'package:gpt_markdown/custom_widgets/custom_error_image.dart'; | @@ -7,6 +7,7 @@ import 'package:gpt_markdown/custom_widgets/custom_error_image.dart'; | ||
7 | import 'package:gpt_markdown/custom_widgets/custom_rb_cb.dart'; | 7 | import 'package:gpt_markdown/custom_widgets/custom_rb_cb.dart'; |
8 | import 'package:gpt_markdown/custom_widgets/markdow_config.dart'; | 8 | import 'package:gpt_markdown/custom_widgets/markdow_config.dart'; |
9 | import 'package:gpt_markdown/custom_widgets/unordered_ordered_list.dart'; | 9 | import 'package:gpt_markdown/custom_widgets/unordered_ordered_list.dart'; |
10 | +import 'package:gpt_markdown/theme.dart'; | ||
10 | import 'md_widget.dart'; | 11 | import 'md_widget.dart'; |
11 | 12 | ||
12 | /// Markdown components | 13 | /// Markdown components |
@@ -413,14 +414,14 @@ class HighlightedText extends InlineMd { | @@ -413,14 +414,14 @@ class HighlightedText extends InlineMd { | ||
413 | style: config.style?.copyWith( | 414 | style: config.style?.copyWith( |
414 | fontWeight: FontWeight.bold, | 415 | fontWeight: FontWeight.bold, |
415 | background: Paint() | 416 | background: Paint() |
416 | - ..color = Theme.of(context).colorScheme.onInverseSurface | 417 | + ..color = GptMarkdownTheme.of(context).highlightColor |
417 | ..strokeCap = StrokeCap.round | 418 | ..strokeCap = StrokeCap.round |
418 | ..strokeJoin = StrokeJoin.round, | 419 | ..strokeJoin = StrokeJoin.round, |
419 | ) ?? | 420 | ) ?? |
420 | TextStyle( | 421 | TextStyle( |
421 | fontWeight: FontWeight.bold, | 422 | fontWeight: FontWeight.bold, |
422 | background: Paint() | 423 | background: Paint() |
423 | - ..color = Theme.of(context).colorScheme.surfaceContainerHighest | 424 | + ..color = GptMarkdownTheme.of(context).highlightColor |
424 | ..strokeCap = StrokeCap.round | 425 | ..strokeCap = StrokeCap.round |
425 | ..strokeJoin = StrokeJoin.round, | 426 | ..strokeJoin = StrokeJoin.round, |
426 | ), | 427 | ), |
gpt_markdown/lib/theme.dart
0 → 100644
1 | +import 'package:flutter/material.dart'; | ||
2 | + | ||
3 | +/// Theme defined for `TexMarkdown` widget | ||
4 | +class GptMarkdownThemeData extends ThemeExtension<GptMarkdownThemeData> { | ||
5 | + GptMarkdownThemeData({ | ||
6 | + required this.highlightColor, | ||
7 | + }); | ||
8 | + | ||
9 | + /// Define default attributes. | ||
10 | + factory GptMarkdownThemeData.from(BuildContext context) { | ||
11 | + return GptMarkdownThemeData( | ||
12 | + highlightColor: Theme.of(context).colorScheme.onInverseSurface, | ||
13 | + ); | ||
14 | + } | ||
15 | + | ||
16 | + Color highlightColor; | ||
17 | + | ||
18 | + @override | ||
19 | + GptMarkdownThemeData copyWith({Color? highlightColor}) { | ||
20 | + return GptMarkdownThemeData( | ||
21 | + highlightColor: highlightColor ?? this.highlightColor, | ||
22 | + ); | ||
23 | + } | ||
24 | + | ||
25 | + @override | ||
26 | + GptMarkdownThemeData lerp(GptMarkdownThemeData? other, double t) { | ||
27 | + if (other == null) { | ||
28 | + return this; | ||
29 | + } | ||
30 | + return GptMarkdownThemeData( | ||
31 | + highlightColor: | ||
32 | + Color.lerp(highlightColor, other.highlightColor, t) ?? highlightColor, | ||
33 | + ); | ||
34 | + } | ||
35 | +} | ||
36 | + | ||
37 | +/// Wrap a `Widget` with `GptMarkdownTheme` to provide `GptMarkdownThemeData` in your intiar app. | ||
38 | +class GptMarkdownTheme extends InheritedWidget { | ||
39 | + const GptMarkdownTheme({ | ||
40 | + super.key, | ||
41 | + required this.gptThemeData, | ||
42 | + required super.child, | ||
43 | + }); | ||
44 | + final GptMarkdownThemeData gptThemeData; | ||
45 | + | ||
46 | + static GptMarkdownThemeData of(BuildContext context) { | ||
47 | + final provider = | ||
48 | + context.dependOnInheritedWidgetOfExactType<GptMarkdownTheme>(); | ||
49 | + if (provider != null) { | ||
50 | + return provider.gptThemeData; | ||
51 | + } | ||
52 | + final themeData = Theme.of(context).extension<GptMarkdownThemeData>(); | ||
53 | + if (themeData != null) { | ||
54 | + return themeData; | ||
55 | + } | ||
56 | + return GptMarkdownThemeData.from(context); | ||
57 | + } | ||
58 | + | ||
59 | + @override | ||
60 | + bool updateShouldNotify(GptMarkdownTheme oldWidget) { | ||
61 | + return gptThemeData != oldWidget.gptThemeData; | ||
62 | + } | ||
63 | +} |
1 | name: gpt_markdown | 1 | name: gpt_markdown |
2 | description: "The purpose of this package is to render the response of ChatGPT into a Flutter app." | 2 | description: "The purpose of this package is to render the response of ChatGPT into a Flutter app." |
3 | -version: 0.1.10 | 3 | +version: 0.1.11 |
4 | homepage: https://github.com/saminsohag/flutter_packages/tree/main/gpt_markdown | 4 | homepage: https://github.com/saminsohag/flutter_packages/tree/main/gpt_markdown |
5 | 5 | ||
6 | environment: | 6 | environment: |
-
Please register or login to post a comment