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