Alpine.js: Sync Trumbowyg Vertical Scrolling with Code
Sync vertical scrolling between two Trumbowyg editors using Alpine.js or vanilla JS. Keeps toolbars visible and avoids page scrolling. Includes runnable code.
How can I synchronize vertical scrolling between two Trumbowyg editor instances (two textarea editors) using Alpine.js and vanilla JavaScript so both editors scroll together while keeping each editor’s toolbar visible? Currently my workaround scrolls the page outside the editor containers and hides the toolbars. What is the correct approach or example code to sync the editors’ internal scroll positions for side-by-side text comparison?
Synchronizing vertical scroll between two Trumbowyg editors using Alpine.js is straightforward once you target the right elements—the .trumbowyg-editor contenteditable divs inside each instance, not the hidden textareas. Your current workaround scrolls the page because it’s likely hooking the outer containers; instead, sync the internal scrollTop positions directly with Alpine.js @scroll events or vanilla JavaScript for bidirectional control. This keeps toolbars pinned and visible via Trumbowyg’s fixedBtnPane logic, perfect for side-by-side text comparison without janky page jumps.
Contents
- Trumbowyg Scroll Mechanics
- Alpine.js Setup for Sync Scroll
- Vanilla JavaScript Sync Approach
- Full Working Alpine.js Example
- Toolbar Visibility and Fixes
- Advanced Library Option
- Sources
- Conclusion
Trumbowyg Scroll Mechanics
Trumbowyg isn’t just a textarea wrapper—under the hood, it’s a full WYSIWYG with a hidden textarea feeding a .trumbowyg-editor div that’s actually scrollable. Why does this trip people up? Most folks target the textarea directly, but that does nothing for visual scrolling. The real action happens in that contenteditable div.
Check the official Trumbowyg documentation—it spells out the structure: toolbar up top (.trumbowyg-button-pane), editable area below (.trumbowyg-editor), and your original textarea stays hidden. Toolbars stay “fixed” thanks to scroll listeners that adjust padding on the box: e.$box.css({paddingTop: toolbarHeight}). Mess with outer scrolls, and boom—toolbars vanish or the page jumps, exactly like your workaround.
For sync scroll between two editors, grab refs to both .trumbowyg-editor elements. Set up listeners on their scroll events. When one fires, mirror scrollTop to the other. Simple? Yeah, but bidirectional means handling both directions without infinite loops—use flags or throttled events.
Ever compared long code diffs side-by-side? This setup shines there, keeping cursors and highlights aligned visually.
Alpine.js Setup for Sync Scroll
Alpine.js makes this declarative and reactive—no jQuery bloat needed. Start with x-data on a parent wrapper holding both editors. Inside, use $refs to access the scrollable divs post-initialization, since Trumbowyg inits them dynamically.
The Alpine.js docs on @scroll are gold: attach @scroll="handleScroll($event)" to each editor container. Browsers smooth-scroll natively, so you get buttery performance. But dots in event names? Use dashes and .dot modifier if needed—@scroll.dot="...".
Here’s the skeleton in your HTML:
<div x-data="editorSync()">
<div x-ref="editor1" class="trumbowyg-editor" @scroll="syncToOther($event, $refs.editor2)"></div>
<!-- Trumbowyg inits here, injecting the real .trumbowyg-editor -->
</div>
Alpine.js computed values can track scroll positions reactively too—handy if you want to persist or animate. But for raw sync, stick to event handlers. Throttle if content’s massive: scroll events fire like crazy.
Your page-scroll issue? That’s because Trumbowyg’s outer .trumbowyg-box might capture events. Isolate to the inner editor divs.
Vanilla JavaScript Sync Approach
Prefer no frameworks? Vanilla JS nails sync scroll textarea vibes reliably. Grab elements after Trumbowyg inits (use trumbowyg.init callbacks), then bind scroll listeners.
Borrow from classics like the jQuery sync example on DotNetCurry—it sets scrollTop bidirectionally:
const editor1 = document.querySelector('#editor1 .trumbowyg-editor');
const editor2 = document.querySelector('#editor2 .trumbowyg-editor');
const syncScroll = (source, target) => {
target.scrollTop = source.scrollTop;
};
editor1.addEventListener('scroll', () => syncScroll(editor1, editor2));
editor2.addEventListener('scroll', () => syncScroll(editor2, editor1));
No loops—user scrolls one, the other follows instantly. Add requestAnimationFrame for smoothness on long content:
const rafSync = (source, target) => {
requestAnimationFrame(() => target.scrollTop = source.scrollTop);
};
This mirrors Stack Overflow discussions where folks hit the same textarea-vs-editor snag. Pro tip: init after Trumbowyg loads, or use MutationObserver for dynamic divs.
Full Working Alpine.js Example
Let’s build it. Assume two Trumbowyg instances side-by-side. Alpine.js handles refs cleanly.
HTML setup:
<!DOCTYPE html>
<html x-data="editorSync()">
<head>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="trumbowyg.css">
<script src="trumbowyg.min.js"></script>
</head>
<body>
<div class="editors-container">
<div class="editor-wrapper">
<textarea id="editor1"></textarea>
</div>
<div class="editor-wrapper">
<textarea id="editor2"></textarea>
</div>
</div>
<script>
function editorSync() {
return {
editor1: null,
editor2: null,
init() {
// Init Trumbowyg first
$('#editor1').trumbowyg({fixedBtnPane: true});
$('#editor2').trumbowyg({fixedBtnPane: true});
// Grab scrollables after init
this.editor1 = document.querySelector('#editor1').closest('.trumbowyg-box').querySelector('.trumbowyg-editor');
this.editor2 = document.querySelector('#editor2').closest('.trumbowyg-box').querySelector('.trumbowyg-editor');
this.editor1.addEventListener('scroll', () => this.syncScroll(this.editor1, this.editor2));
this.editor2.addEventListener('scroll', () => this.syncScroll(this.editor2, this.editor1));
},
syncScroll(source, target) {
target.scrollTop = source.scrollTop;
}
}
}
</script>
</body>
</html>
Boom—scroll one, both move. Alpine.js computed values? Add scrollPos: 0 and $watch('scrollPos', val => { this.editor2.scrollTop = val; }), but events suffice for most.
Tested this? Line up matching text; cursors stay synced. For diffs, highlight changes—scroll locks 'em tight.
Toolbar Visibility and Fixes
Toolbars hiding? Trumbowyg’s fixedBtnPane: true listens to box scrolls, not inner editor ones. Your page scroll workaround triggered outer events, fooling the padding calc.
Fix: Ensure listeners are strictly on .trumbowyg-editor. If toolbars still dip, force the padding refresh:
const refreshToolbar = (box) => {
const toolbar = box.querySelector('.trumbowyg-button-pane');
box.style.paddingTop = toolbar.offsetHeight + 'px';
};
Call after sync. Or disable fixed mode and position manually—position: sticky on toolbars works cross-browser now.
Edge cases? Mobile touch-scrolls lag? Throttle with lodash or native:
let ticking = false;
function throttledSync(source, target) {
if (!ticking) {
requestAnimationFrame(() => {
target.scrollTop = source.scrollTop;
ticking = false;
});
ticking = true;
}
}
Sync scroll feels native. Resize windows? Add @resize.window in Alpine to rebind refs.
Advanced Library Option
Want zero boilerplate? Drop in syncscroll library—946 bytes, vanilla JS. Just add class="syncscroll" name="mygroup" to both .trumbowyg-editor divs post-init:
syncscroll({groups: [{name: 'mygroup'}]});
It handles multi-element sync out-of-box, even horizontal if needed. Like diff2html’s side-by-side, but pure scroll. Integrate with Alpine.js via x-init.
Overkill for two editors? Maybe. But scales if you add more panes. No perf hit—micro indeed.
Sources
- Stack Overflow: Sync scroll between 2 textarea editors
- GitHub: asvd/syncscroll
- .NET Curry: Synchronize Scrolling of Two Multiline TextBoxes
- Trumbowyg Documentation
- Alpine.js: on directive
- GitHub: rtfpessoa/diff2html
Conclusion
Syncing Trumbowyg editors with Alpine.js boils down to targeting .trumbowyg-editor scrollTop—ditch page hacks for internal events, and toolbars stay put. Grab the full example, tweak for your diffs, and you’ll have responsive side-by-side comparison in minutes. Vanilla works too; scale with syncscroll if needed. Questions on alpine js computed values for positions? It’s reactive magic waiting.