iTranslated by AI
Implementing Raw View to Show Markdown Source Instead of Cards — villar v0.4.0
"Can you disable the card view?"
The day after I posted my article about villar on Zenn, I received some immediate feedback.
The card display is nice, but sometimes I want to see the original Markdown as it is.
To be honest, I hadn't anticipated this. I built an app that "splits Markdown into cards to make it easier to read," only to be told, "Let me see it without splitting it."
But when I thought about it, I have those moments too. When reading AI output, card view is great. However, when I want to copy and paste the Markdown elsewhere, I can't get the original source from the split card state. Sometimes I wonder, "How was that code block written in the original text?"
Adding a "don't read" mode to an app built for reading—it feels a bit contradictory, but I decided to go for it.
A simple button, but a massive scope of impact
The plan was to just place a single "Raw" button that displays the Markdown source when clicked. That's the entire functionality.
However, I realized once I started implementing it that every single feature in villar operated on the premise that "cards exist."
- Keyboard shortcuts: Left/Right for moving between cards, Home/End for the first/last card. It makes no sense for these to work while in Raw view.
- Outline: Clicking an H2 heading jumps to a card. Where should it jump during Raw view?
- Menu bar: Menu items like "Previous Card" and "Next Card." These need to be grayed out in Raw view.
- Focus mode: Dims everything except the active card. What do you dim if there are no cards?
-
Scroll progress: Calculated based on card index. Since Raw view is just one
<pre>element, the calculation needs to be redone based on scroll position.
It was supposed to be a simple one-button addition, but after mapping out the impact, my Todo list grew to 12 items. Underestimating the scope of feature additions is a classic tradition for engineers.
useState vs Zustand: Struggling with where to put the state
At first, I managed it using useState within the CardView.
const [rawMode, setRawMode] = useState(false);
It was simple and good. But I hit a wall quickly.
The keyboard shortcut handlers are in a separate custom hook called useKeyboardNavigation. The menu bar handlers are in useMenuActions. Clicking the outline is handled by the Outline component. Everyone needs to know, "Is rawMode active right now?"
With useState, the prop-drilling begins: CardView → App → useKeyboardNavigation, CardView → App → Sidebar → Outline... villar's component tree is relatively shallow, but it's still painful.
Ultimately, I moved it into a Zustand store.
// useAppStore
rawMode: false,
setRawMode: (v: boolean) => set({ rawMode: v }),
Now it can be referenced from anywhere using useAppStore(s => s.rawMode). The keyboard handlers, the menu, and the outline all just look at the store.
Using Zustand for just "a single boolean" might feel like overkill. But since villar was already using Zustand for state management, the added cost is virtually zero. When you're unsure where to put a new state, try listing three or more places that need to read that value. If there are three or more, a global store is usually easier.
RawView: A component completed in 48 lines
RawView itself was surprisingly simple.
export function RawView({ content, onScrollProgress, scrollRef }: RawViewProps) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
<button onClick={handleCopy} className="absolute top-3 right-3 ...">
{copied ? "Copied!" : "Copy All"}
</button>
<pre className="p-6 text-sm font-mono whitespace-pre-wrap break-words">
{content}
</pre>
</div>
);
}
Just feed the Markdown source into a <pre> and place a copy button in the top right. That's it. 48 lines.
Out of the 10,000 lines of code in villar, the fact that the feature users liked the most was written in just 48 lines feels like there's a lesson to be learned. While I spent thousands of lines on fancy card displays, a single <pre> tag solved the problem.
Copy All: Unassumingly the most used feature
The "Copy All" button, which copies the source directly to the clipboard, turned out to be unexpectedly convenient.
Users read AI output in villar and copy the good parts directly into their own documents. The workflow that previously required "re-opening in VS Code, Cmd+A, then Cmd+C" can now be completed entirely within villar.
Implementation was just one line: navigator.clipboard.writeText(content). There is nothing to speak of technically. But its contribution to the user experience is about 100 times that of the Mermaid parser. To think of how many days I spent on that parser...
The Battle with the Keyboard
villar has a plethora of keyboard shortcuts: ← and → to navigate cards, Home/End to jump to the start/end, F for focus mode, and Space for page scrolling.
It becomes problematic when these are active during Raw view. If you press ←, it triggers "previous card" and tries to navigate to a card that doesn't exist.
However, disabling everything would also kill page scrolling via the Space key, which I still want to use even in Raw view.
I ended up doing this:
- Disabled: ←→, Home/End, F (Card navigation system)
- Left enabled: Space/Shift+Space (Scrolling system), Cmd+F (Search), Cmd+K (Global search)
Since I just insert if (rawMode) return; at the beginning of each handler, the implementation is just one line each. However, deciding "which keys to kill and which to keep alive" took longer than the actual implementation.
H2 scrolling in the outline: No ID
In card view, clicking an H2 heading in the outline jumps to that card. It's easy because it's managed by card index.
In Raw view, since there are no cards, I need to find the position of the H2 within the <pre> and scroll to it.
At first, I tried to count the lines of Markdown ## Heading using regex and calculate the scroll position by multiplying line height by the line number. However, since <pre> uses white-space: pre-wrap, long lines wrap. The number of lines didn't match the actual Y-coordinate.
Next, I considered finding the text within the DOM. Since the content of <pre> is plain text, there are no child elements. No IDs, no classes.
In the end, I used a method where I find the H2 line within the <pre> text and calculate the height of the string up to that point using a temporary DOM element. Because it performs a perfect match search on the heading text content, it doesn't rely on IDs.
Honestly, it's not a very elegant implementation, but it works. And working is a beautiful thing.
The issue of being unable to select text
I'll talk about the most embarrassing bug in v0.4.0.
In villar, clicking a card makes it active. I attached an onClick handler to the entire card so that clicking anywhere on the card triggers a reaction.
This onClick was killing text selection.
Drag to select text → release mouse → onClick fires → card becomes active → selection disappears.
I didn't notice until a user reported that they "couldn't copy the text." Even though I was using it myself every day. I hadn't tested the basic operation of selecting and copying text. Even though it's an app for "reading" Markdown.
The fix was one line:
if (window.getSelection()?.toString()) return;
At the beginning of onClick, I check "if text is selected, do nothing." This skips activating the card while text is being selected.
It was a one-line fix, but it took a week to discover the bug. It goes without saying that I added "ensure text can be selected and copied" to the tests.
Reading Ruler, again
In my previous article, I wrote about my "3-day battle with CSS zoom." At that time, I solved it by moving the Reading Ruler outside of the zoom, or so I thought.
It re-emerged in v0.4.0.
To be precise, it was naive of me to think it was "solved." Moving it outside the zoom was fine, but because I was calculating the position directly from e.clientY, the offset for the header at the top of the browser window was missing. The ruler would be off by the height of the header.
As a fix, I attached a data-ruler-bounds attribute to the wrapper element outside the zoom and used getBoundingClientRect() to get the accurate drawing area.
I really hate CSS zoom. But implementing "changing font size from 50% to 150%" without CSS zoom would require dynamically changing the font specifications for every component. That would be even worse.
The scrollbar is thick
This is more of an aesthetic issue than a bug.
The default macOS scrollbar is quite thick when active. When this thick scrollbar appears in villar's card display area, it ruins the theme's atmosphere. A thick gray bar sitting next to Dracula's beautiful dark purple.
I customized the scrollbar with CSS.
- Width 6px (less than half of default)
- Track transparent
- Thumb is
rgba(128,128,128,0.2) - Becomes slightly darker on hover
Spending 30 minutes on just a scrollbar is terrible for productivity. But once you start caring about the details of an app you use every day, you have to fix them, or it'll feel wrong and you won't be able to use it. You could call it the thrill of individual development, or a curse.
v0.4.0 numbers
- New components: 1 (RawView.tsx, 48 lines)
- Modified files: 8
- Feature most liked by users: Copy All (1 line of
navigator.clipboard.writeText) - Fix that took the most time: Reading Ruler (again)
- Most embarrassing bug: Unable to copy text
A 48-line component and a 1-line bug fix. If I had to sum up v0.4.0 in one phrase, it would be "smaller changes hit users harder."
It makes me a little sad to remember the days I spent on the Mermaid parser, but that is that, and this is this.
Series List:
- I couldn't read the 4000-line Markdown output by AI, so I made a reader app with Tauri v2
- I failed miserably by splitting with H1 — Showing the entire villar Markdown pipeline
- I made 46 themes and half-regretted it — Looking back on 2 months of villar development
- Raw View created after being told "I don't need card view, show me the Markdown" (This article)
Discussion