Wails in Practice #7 The Note Editor for Real — Markdown Preview and List UX

5 min read

Part 2 starts here. Part 1 (#1#6) took the notes app all the way to a shippable state, but the front half the user touches every day we kept punting on with “assume a lightweight component framework.” Part 2 covers the polish that makes the app worth opening again, and its first post is the editor. We actually implement the core experience of a markdown notes app — editing and preview.

This series is ten posts in two parts.

  • Part 1 — build and ship the app: #1 · #2 · #3 · #4 · #5 · #6
  • Part 2 — polish and repeat readers
    • #7 The note editor for real — markdown preview and list UX ← this post
    • #8 A testing strategy — verifying the service and repository
    • #9 Implementing auto-update — delivering a new version safely
    • #10 Finishing touches — settings, dark mode, data backup

Backend for editing: an Update method #

The repository built in #2 had only create, list, and search. Editing needs an update, so add Update to the repository and service. The key is that the service refreshes UpdatedAt to the current time, because that value is the basis for sorting the list newest-first in #3.

service.go — update
func (s *NoteService) Update(id int64, title, body string) (Note, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Note{}, errors.New("please enter a title")
	}
	return s.repo.Update(Note{
		ID: id, Title: title, Body: body, UpdatedAt: time.Now(),
	})
}

The binding layer, following the same pattern as #3, emits the notes:changed event after updating so the list redraws automatically.

Preview: render markdown safely #

A markdown notes app has to show the edited text as formatted output. You use a library on the frontend that turns markdown into HTML (say, marked), and here is a practical trap. A note body is user-written content, and inserting it as HTML directly opens an XSS risk. A <script> or an onerror attribute in the body could execute as-is.

Keep the habit even in an app only you use. After converting markdown to HTML, always run it through a sanitizer (say, DOMPurify) to strip dangerous tags and attributes before inserting.

preview.js — sanitize after rendering
import { marked } from "marked";
import DOMPurify from "dompurify";

export function renderMarkdown(source) {
  const rawHtml = marked.parse(source);
  return DOMPurify.sanitize(rawHtml); // return after stripping dangerous tags/attrs
}

The preview is commonly a split view alongside the editor. Render the body being edited with the function above and draw it on the right, and formatting shows as you type.

Autosave: save when typing stops #

Making the user press a “Save” button is a dated experience. In practice you autosave when typing pauses briefly. But calling save on every keystroke floods the DB with writes, so use a debounce so it saves once only after some time has passed since the last input.

editor.js — debounced autosave
import { UpdateNote } from "../wailsjs/go/main/App";

let saveTimer;
function scheduleSave(id, title, body) {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(async () => {
    try {
      await UpdateNote(id, title, body);
      setStatus("Saved");
    } catch (err) {
      setStatus("Save failed: " + err); // #5's rejected Promise lands here
    }
  }, 600); // 600ms after the last input
}

Showing “Saving… / Saved / Save failed” with setStatus lets the user know their writing is safe without a button. A save failure is caught as the rejected Promise Go returns, covered in #5, and shown on screen.

Handling sort shuffle #

Autosave hides a subtle UX problem. Every save refreshes UpdatedAt, and the list is newest-first, so the note being edited jumps to the top of the list on every autosave. The screen keeps shuffling, which is jarring.

There are two fixes. One is to ignore list-refresh events while editing and redraw the list only when editing ends or you move to another note. The other is to sort the list by CreatedAt (fixed) rather than UpdatedAt, with a separate sort toggle if newest-edited order is genuinely needed. In practice the first — freezing the list while editing — feels natural.

defer list refresh while editing
let editing = false;

EventsOn("notes:changed", async () => {
  if (editing) return;         // do not shuffle the list while editing
  notes = await Search(currentQuery);
});

List UX: move with the keyboard #

A note tool should let you move fast without a mouse. Add keyboard navigation that moves the selection with up/down arrows in the list and focuses the editor on Enter. Add guidance screens for empty states (no notes at all, no search results) on top of that, and the app never loses its way even when empty.

This front-half work does not lean heavily on a framework. Move the selection index with an arrow-key handler, load the selected note into the editor, and hand saving off to the debounce above — that flow is the same structure in vanilla or in Svelte/Vue. Keeping the principle set in #1 (the truth of the data lives in Go), the front half only has to handle screen and input.

Summary #

  • Add Update to the repository and service for editing, and have the service refresh UpdatedAt. Redraw the list with notes:changed after updating.
  • The markdown preview must sanitize rendered HTML (DOMPurify and the like) before inserting. Even in a local app, the body is user input, so block XSS.
  • Autosave debounces to save once after the last input and shows “Saved/failed” status, removing the save button.
  • Handle the shuffle where autosave jumps a note to the top by freezing list refresh while editing.
  • Finish the list UX with keyboard navigation and empty-state guidance. The front half handles only screen and input; the truth of the data lives in Go.
  • Next post verifies the service and repository built so far with real tests.
X