Last active
February 5, 2024 14:00
-
-
Save PlugFox/a88aa91ce1020d00a1028de7a02c56f7 to your computer and use it in GitHub Desktop.
Flutter Web | Inline HTML | Shadow root | Resume builder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'package:flutter/material.dart'; | |
import 'package:playground/src/resume_preview.dart'; | |
void main() => runZonedGuarded<Future<void>>( | |
() async { | |
runApp(const App()); | |
}, | |
(error, stackTrace) => | |
// ignore: avoid_print | |
print('Top level exception: $error\n$stackTrace'), | |
); | |
/// {@template app} | |
/// App widget. | |
/// {@endtemplate} | |
class App extends StatelessWidget { | |
/// {@macro app} | |
const App({super.key}); | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
title: 'Resume Builder', | |
theme: ThemeData.dark(), | |
home: const WebViewScreen(), | |
); | |
} | |
/// {@template webview_app} | |
/// WebViewScreen widget. | |
/// {@endtemplate} | |
class WebViewScreen extends StatefulWidget { | |
/// {@macro webview_app} | |
const WebViewScreen({ | |
super.key, // ignore: unused_element | |
}); | |
/// Returns the [TextEditingController] from the closest [WebViewScreen] ancestor. | |
static TextEditingController of(BuildContext context, {bool listen = true}) => | |
context.findAncestorStateOfType<_WebViewScreenState>()!.controller; | |
@override | |
State<WebViewScreen> createState() => _WebViewScreenState(); | |
} | |
/// State for widget WebViewScreen. | |
class _WebViewScreenState extends State<WebViewScreen> { | |
bool _show = true; | |
double _size = 75; | |
bool get _isSizeMax => _size >= 100; | |
bool get _isSizeMin => _size <= 25; | |
final FocusNode focusNode = FocusNode(); | |
final TextEditingController controller = | |
TextEditingController(text: 'John Doe'); | |
void _updateSize(double value) => setState(() => _size = value); | |
@override | |
void dispose() { | |
controller.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
appBar: AppBar( | |
title: const Text( | |
'Resume Builder', | |
style: TextStyle( | |
color: Colors.white, | |
fontSize: 24, | |
), | |
), | |
backgroundColor: Colors.blueGrey, | |
centerTitle: true, | |
), | |
floatingActionButton: Padding( | |
padding: const EdgeInsets.only(bottom: 64), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
FloatingActionButton( | |
onPressed: () => setState(() => _show = !_show), | |
child: AnimatedSwitcher( | |
duration: const Duration(milliseconds: 500), | |
transitionBuilder: (child, animation) => FadeTransition( | |
opacity: animation, | |
child: ScaleTransition( | |
scale: animation, | |
child: child, | |
), | |
), | |
child: _show | |
? const Icon(Icons.visibility_off, key: ValueKey(true)) | |
: const Icon(Icons.visibility, key: ValueKey(false)), | |
), | |
), | |
const SizedBox(height: 8), | |
AnimatedOpacity( | |
duration: const Duration(milliseconds: 500), | |
opacity: _isSizeMax ? 0.25 : 1, | |
child: FloatingActionButton( | |
onPressed: _isSizeMax | |
? null | |
: () => _updateSize((_size + 25).clamp(25, 100)), | |
child: const Icon(Icons.add), | |
), | |
), | |
const SizedBox(height: 8), | |
AnimatedOpacity( | |
duration: const Duration(milliseconds: 500), | |
opacity: _isSizeMin ? 0.25 : 1, | |
child: FloatingActionButton( | |
onPressed: _isSizeMin | |
? null | |
: () => _updateSize((_size - 25).clamp(25, 100)), | |
child: const Icon(Icons.remove), | |
), | |
), | |
], | |
), | |
), | |
body: SafeArea( | |
child: Column( | |
mainAxisSize: MainAxisSize.max, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: <Widget>[ | |
Padding( | |
padding: const EdgeInsets.all(16), | |
child: SizedBox( | |
height: 64, | |
width: 420, | |
child: TextFormField( | |
decoration: InputDecoration( | |
isCollapsed: false, | |
isDense: false, | |
floatingLabelBehavior: FloatingLabelBehavior.auto, | |
border: OutlineInputBorder( | |
borderRadius: | |
const BorderRadius.all(Radius.circular(12)), | |
borderSide: BorderSide( | |
width: 1, | |
color: Colors.blueGrey.shade200, | |
), | |
), | |
hoverColor: Colors.blueGrey.shade100, | |
labelText: 'Name', | |
hintText: 'Enter your name', | |
helperText: null, | |
suffixIcon: const Icon(Icons.search), | |
counter: const SizedBox.shrink(), | |
errorText: null, | |
helperMaxLines: 0, | |
errorMaxLines: 0, | |
), | |
controller: controller, | |
focusNode: focusNode, | |
maxLines: 1, | |
expands: false, | |
keyboardType: TextInputType.text, | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 16, | |
), | |
), | |
), | |
), | |
Expanded( | |
child: Theme( | |
data: ThemeData.light(), | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Center( | |
child: _show | |
? LayoutBuilder( | |
builder: (context, constraints) => SizedBox( | |
width: constraints.maxWidth * _size / 100, | |
height: constraints.maxHeight * _size / 100, | |
child: const Center( | |
child: AspectRatio( | |
aspectRatio: 1 / 1.414, | |
child: Card( | |
child: Padding( | |
padding: EdgeInsets.all(8.0), | |
child: ResumePreview(), | |
), | |
), | |
), | |
), | |
), | |
) | |
: const SizedBox.shrink(), | |
), | |
), | |
), | |
), | |
SizedBox( | |
height: 64, | |
child: DecoratedBox( | |
decoration: BoxDecoration( | |
color: Colors.blueGrey, | |
border: Border.all(color: Colors.black), | |
), | |
child: Center( | |
child: Text( | |
'Size: ${_size.toStringAsFixed(0)}%', | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 24, | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'platform/resume_preview_js.dart' | |
// ignore: uri_does_not_exist | |
if (dart.library.io) 'platform/resume_preview_vm.dart'; | |
/// {@template resume_preview} | |
/// ResumePreview widget. | |
/// {@endtemplate} | |
class ResumePreview extends StatelessWidget { | |
/// {@macro resume_preview} | |
const ResumePreview({ | |
super.key, // ignore: unused_element | |
}); | |
@override | |
Widget build(BuildContext context) => $buildResumePreview(context); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
String get $cvInlineStyle => r''' | |
div.a4-wrapper { | |
display: flex; | |
width: 100%; | |
height: 100%; | |
border: none; /* 1px solid #ccc; */ | |
position: relative; | |
box-shadow: none; /* 0 4px 6px rgba(0,0,0,0.1); */ | |
justify-content: center; | |
transform-origin: top left; | |
transition: transform 0.3s ease; | |
overflow-y: auto; | |
overflow-x: hidden; | |
padding: 0px; | |
box-sizing: border-box; | |
font-family: Arial, sans-serif; | |
font-size: 16px; | |
line-height: 1.5; | |
color: #333; | |
/* scrollbar-width: thin; | |
scrollbar-color: #363636 #e7e7e7; */ | |
} | |
div.a4-wrapper::-webkit-scrollbar { | |
width: 12px; | |
} | |
div.a4-wrapper::-webkit-scrollbar-track { | |
border-radius: 8px; | |
background-color: #e7e7e7; | |
border: 1px solid #cacaca; | |
box-shadow: inset 0 0 6px rgba(0, 0, 0, .3); | |
} | |
div.a4-wrapper::-webkit-scrollbar-thumb { | |
border-radius: 8px; | |
background-color: #363636; | |
} | |
div.a4-wrapper::-webkit-scrollbar-thumb:hover { | |
background: #555; | |
} | |
div.a4-content { | |
font-family: Arial, sans-serif; | |
width: 794px; | |
min-height: 1122px; /* A4 size */ | |
box-sizing: border-box; | |
padding: 20px; | |
} | |
.header { text-align: center; } | |
.section { margin-top: 20px; } | |
.section-title { font-size: 24px; color: #333; } | |
.list { margin-top: 10px; } | |
.item { margin-bottom: 10px; } | |
'''; | |
String get $cvInlineContent => r''' | |
<div class="header"> | |
<h1 class="cv-template-name"> </h1> | |
<p>Email: alexey@designer.com | Phone: +7 999 123 45 67 | City: Moscow</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Work Experience</h2> | |
<ul class="list"> | |
<li class="item"> | |
<strong>Senior Designer</strong> - Design Studio Alpha, Moscow (2020-2023) | |
<p>Developing concepts, creating visual solutions for clients, teamwork on projects.</p> | |
</li> | |
<li class="item"> | |
<strong>Junior Designer</strong> - Creative Agency Beta, Saint Petersburg (2017-2020) | |
<p>Supporting design projects, creating layouts and prototypes, working with clients.</p> | |
</li> | |
</ul> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Education</h2> | |
<ul class="list"> | |
<li class="item"> | |
<strong>Master of Design</strong> - Moscow State University of Design and Technology (2013-2017) | |
</li> | |
<li class="item"> | |
<strong>Additional Courses</strong> - UX/UI Design, Skillbox (2018) | |
</li> | |
</ul> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Lorem ipsum</h2> | |
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Lorem ipsum</h2> | |
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Lorem ipsum</h2> | |
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Lorem ipsum</h2> | |
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Lorem ipsum</h2> | |
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> | |
</div> | |
<div class="section"> | |
<h2 class="section-title">Skills</h2> | |
<ul class="list"> | |
<li class="item">Adobe Photoshop, Adobe Illustrator</li> | |
<li class="item">Sketch, Figma</li> | |
<li class="item">Understanding of composition and typography principles</li> | |
<li class="item">User Interface Design and Web Design</li> | |
</ul> | |
</div> | |
'''; | |
String get $cvInlineScript => r''' | |
console.log('Embedded CV script loaded'); | |
'''; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ignore_for_file: avoid_web_libraries_in_flutter | |
import 'dart:html' as html; | |
import 'dart:ui_web' as ui; | |
import 'package:flutter/material.dart'; | |
import 'package:playground/src/platform/resume_preview_content.dart'; | |
import 'package:playground/main.dart'; | |
Widget $buildResumePreview(BuildContext context) => const ResumePreviewJS(); | |
/// {@template resume_preview_js} | |
/// ResumePreviewJS widget. | |
/// {@endtemplate} | |
class ResumePreviewJS extends StatefulWidget { | |
/// {@macro resume_preview_js} | |
const ResumePreviewJS({ | |
super.key, // ignore: unused_element | |
}); | |
@override | |
State<ResumePreviewJS> createState() => _ResumePreviewJSState(); | |
} | |
class _ResumePreviewJSState extends State<ResumePreviewJS> { | |
late final TextEditingController _controller; | |
html.ShadowRoot? _shadowRoot; | |
@override | |
void initState() { | |
super.initState(); | |
ui.platformViewRegistry.registerViewFactory( | |
'embedded-cv-view', | |
(int viewId) => html.DivElement() | |
..style.display = 'block' | |
..style.width = '100%' // 100% of parent (Scaffold) | |
..style.height = '100%' // 100% of parent (Scaffold) | |
..style.overflowY = 'hidden' | |
..style.overflowX = 'hidden' | |
..style.border = 'none' | |
..classes.addAll(['embedded-cv-view']), | |
isVisible: true, | |
); | |
_controller = WebViewScreen.of(context)..addListener(_onNameChanged); | |
html.window.console.log('ResumePreviewJS view factory registered'); | |
} | |
void _appendShadowContent() { | |
final view = html.document.querySelector('.embedded-cv-view'); | |
if (view == null) { | |
html.window.console.error('Embedded CV view not found'); | |
return; | |
} | |
final sanitizer = html.NodeTreeSanitizer( | |
html.NodeValidatorBuilder.common() | |
..allowInlineStyles() | |
..allowElement('style', attributes: ['type']) | |
..allowElement('script', attributes: ['src']), | |
); | |
final content = html.DivElement() | |
..classes.addAll(['a4-wrapper', 'cv-shadow-container']) | |
..append(html.DivElement() | |
..classes.add('a4-content') | |
..appendHtml($cvInlineContent, treeSanitizer: sanitizer)); | |
final style = html.StyleElement()..text = $cvInlineStyle; | |
final script = html.ScriptElement()..text = $cvInlineScript; | |
_shadowRoot = (view.shadowRoot ?? view.attachShadow({'mode': 'open'})) | |
..append(style) | |
..append(content) | |
..append(script); | |
_shadowRoot?.addEventListener('wheel', _onScrollEvent); | |
_onNameChanged(); | |
html.window.console.log('Shadow content appended'); | |
} | |
void _onNameChanged() { | |
final name = _controller.text.trim(); | |
final nameTemplate = | |
_shadowRoot?.querySelector('.header > h1.cv-template-name'); | |
if (nameTemplate == null) { | |
html.window.console.error('Name template not found'); | |
return; | |
} | |
nameTemplate.text = name.trim(); | |
html.window.console.log('Name changed: $name'); | |
} | |
void _onScrollEvent(html.Event event) { | |
if (event is! html.WheelEvent) return; | |
final wrapper = _shadowRoot?.querySelector('.a4-wrapper'); | |
if (wrapper == null) return; | |
event.preventDefault(); | |
wrapper.scrollTop += event.deltaY.toInt(); | |
} | |
@override | |
void dispose() { | |
_controller.removeListener(_onNameChanged); | |
_shadowRoot?.removeEventListener('wheel', _onScrollEvent); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => FittedBox( | |
alignment: Alignment.center, | |
clipBehavior: Clip.hardEdge, | |
fit: BoxFit.contain, | |
child: SizedBox( | |
width: 794, | |
height: 1122, | |
child: HtmlElementView( | |
viewType: 'embedded-cv-view', | |
onPlatformViewCreated: (_) => _appendShadowContent(), | |
), | |
), | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:playground/src/platform/resume_preview_content.dart'; | |
import 'package:playground/main.dart'; | |
import 'package:webview_flutter/webview_flutter.dart' as wv; | |
Widget $buildResumePreview(BuildContext context) => const ResumePreviewVM(); | |
/// {@template resume_preview_vm} | |
/// ResumePreviewVM widget. | |
/// {@endtemplate} | |
class ResumePreviewVM extends StatefulWidget { | |
/// {@macro resume_preview_vm} | |
const ResumePreviewVM({ | |
super.key, // ignore: unused_element | |
}); | |
@override | |
State<ResumePreviewVM> createState() => _ResumePreviewVMState(); | |
} | |
class _ResumePreviewVMState extends State<ResumePreviewVM> { | |
bool _contentLoaded = false; | |
late final wv.WebViewController _wvController; | |
late final TextEditingController _nameController; | |
@override | |
void initState() { | |
final buffer = StringBuffer() | |
..writeln('<html>') | |
..writeln('<style>') | |
..writeln($cvInlineStyle) | |
..writeln('</style>') | |
..writeln('<body>') | |
..writeln('<div class="a4-wrapper">') | |
..writeln('<div class="a4-content">') | |
..writeln($cvInlineContent) | |
..writeln('</div>') | |
..writeln('<script>') | |
..writeln($cvInlineScript) | |
..writeln('</script>') | |
..writeln('</div>') | |
..writeln('</body>') | |
..writeln('</html>'); | |
_wvController = wv.WebViewController() | |
..setJavaScriptMode(wv.JavaScriptMode.unrestricted) | |
..setBackgroundColor(const Color(0x00000000)) | |
..setOnConsoleMessage(print) // ignore: avoid_print | |
..enableZoom(false) | |
..setNavigationDelegate(wv.NavigationDelegate( | |
onNavigationRequest: (_) => wv.NavigationDecision.prevent)) | |
..loadHtmlString(buffer.toString()).whenComplete(() { | |
_contentLoaded = true; | |
_onNameChanged(); | |
}); | |
_nameController = WebViewScreen.of(context)..addListener(_onNameChanged); | |
WidgetsBinding.instance.addPostFrameCallback((_) => _onNameChanged()); | |
super.initState(); | |
} | |
void _onNameChanged() { | |
if (!_contentLoaded || !mounted) return; | |
final name = _nameController.text.trim(); | |
_wvController.runJavaScript( | |
'document.querySelector(".cv-template-name").textContent = "$name";'); | |
} | |
@override | |
void dispose() { | |
_nameController.removeListener(_onNameChanged); | |
_wvController | |
..clearCache() | |
..clearLocalStorage(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => FittedBox( | |
alignment: Alignment.center, | |
clipBehavior: Clip.hardEdge, | |
fit: BoxFit.contain, | |
child: SizedBox( | |
width: 794, | |
height: 1122, | |
child: RepaintBoundary( | |
child: wv.WebViewWidget( | |
controller: _wvController, | |
layoutDirection: TextDirection.ltr, | |
gestureRecognizers: const <Factory< | |
OneSequenceGestureRecognizer>>{}, | |
), | |
), | |
), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment