Skip to content

Chapter 6 of 19

Develop with Fast Refresh ​

Restyle the preview while a note stays open, and see what Fast Refresh keeps and when the app restarts instead.

What Fast Refresh keeps ​

npm run dev watches the project's source files. When you save a component module, Fast Refresh runs the new code and renders its components again in the open window. Everything else stays as it was: React state such as the navigator's open note and the preview toggle in App, and the GTK widgets the edit didn't change, such as the editor with its cursor and undo history.

src/preview/markdown-preview.tsx exports only MarkdownPreview, and its other components are private to the module, so a save refreshes it in place. Saving a module that exports something other than components, such as toMarkup in markup.ts, refreshes the components that import it. The terminal prints Running Fast Refresh... and Fast Refresh complete for each save.

Restyle the preview ​

Open a note with a quote and a code block. If your folder has none, save this as Meeting notes.md in it:

md
# Meeting notes

Kickoff with design and docs, Tuesday 10:00.

> Launch when the content is ready, not before.

Preview the new site locally:

```sh
npm install
npm run docs:dev
```

The preview renders the quote as dimmed text and the code block as a plain monospace label. Change CodeBlock and QuoteBlock in src/preview/markdown-preview.tsx, saving after each:

src/preview/markdown-preview.tsx
tsx
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwClamp } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkLabel,
    GtkScrolledWindow,
    GtkSeparator,
} from "@gtkx/jsx/gtk";
import type {
    Blockquote,
    Code,
    Heading,
    List,
    Paragraph,
    RootContent,
} from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { createContext, use, useMemo } from "react";
import { toMarkup } from "./markup.js";

const LinkContext = createContext<(uri: string) => boolean>(() => false);

const HEADING_CLASSES = [
    "title-1",
    "title-2",
    "title-3",
    "title-4",
    "heading",
    "heading",
];

type TextBlockProps = {
    markup: string;
    cssClasses?: string[];
    accessibleRole?: Gtk.AccessibleRole;
};

const TextBlock = ({
    markup,
    cssClasses = [],
    accessibleRole = Gtk.AccessibleRole.LABEL,
}: TextBlockProps) => {
    const onLink = use(LinkContext);

    return (
        <GtkLabel
            accessibleRole={accessibleRole}
            label={markup}
            useMarkup
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            cssClasses={cssClasses}
            onActivateLink={(uri) => onLink(uri)}
        />
    );
};

const HeadingBlock = ({ node }: { node: Heading }) => (
    <TextBlock
        accessibleRole={Gtk.AccessibleRole.HEADING}
        markup={toMarkup(node.children)}
        cssClasses={[HEADING_CLASSES[node.depth - 1] ?? "heading"]}
    />
);

const ParagraphBlock = ({ node }: { node: Paragraph }) => (
    <TextBlock markup={toMarkup(node.children)} />
);

const CodeBlock = ({ node }: { node: Code }) => (
    <GtkBox cssClasses={["card"]}>
        <GtkLabel
            label={node.value}
            selectable
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            hexpand
            marginTop={12}
            marginBottom={12}
            marginStart={12}
            marginEnd={12}
            cssClasses={["monospace"]}
        />
    </GtkBox>
);

const QuoteBlock = ({ node }: { node: Blockquote }) => (
    <GtkBox spacing={12}>
        <GtkSeparator orientation={Gtk.Orientation.VERTICAL} />
        <GtkBox
            orientation={Gtk.Orientation.VERTICAL}
            spacing={12}
            hexpand
            cssClasses={["dimmed"]}
        >
            <Blocks nodes={node.children} />
        </GtkBox>
    </GtkBox>
);

const ListBlock = ({ node }: { node: List }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={node.spread ? 12 : 6}
    >
        {node.children.map((item, index) => (
            <GtkBox key={index} spacing={8}>
                <GtkLabel
                    label={node.ordered ? `${(node.start ?? 1) + index}.` : "•"}
                    valign={Gtk.Align.START}
                    widthChars={2}
                    xalign={1}
                />
                <GtkBox
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={6}
                    hexpand
                >
                    <Blocks nodes={item.children} />
                </GtkBox>
            </GtkBox>
        ))}
    </GtkBox>
);

const Block = ({ node }: { node: RootContent }) => {
    switch (node.type) {
        case "heading":
            return <HeadingBlock node={node} />;
        case "paragraph":
            return <ParagraphBlock node={node} />;
        case "code":
            return <CodeBlock node={node} />;
        case "blockquote":
            return <QuoteBlock node={node} />;
        case "list":
            return <ListBlock node={node} />;
        case "thematicBreak":
            return <GtkSeparator />;
        case "html":
            return (
                <TextBlock
                    markup={toMarkup([node])}
                    cssClasses={["monospace", "dimmed"]}
                />
            );
        default:
            return null;
    }
};

const Blocks = ({ nodes }: { nodes: RootContent[] }) =>
    nodes.map((node, index) => <Block key={index} node={node} />);

type MarkdownPreviewProps = {
    text: string;
    onActivateLink?: (uri: string) => boolean;
};

const MarkdownPreview = ({
    text,
    onActivateLink = () => false,
}: MarkdownPreviewProps) => {
    const { t } = useTranslation();
    const tree = useMemo(() => fromMarkdown(text), [text]);

    return (
        <GtkScrolledWindow vexpand hscrollbarPolicy={Gtk.PolicyType.NEVER}>
            <AdwClamp maximumSize={720}>
                <GtkBox
                    accessibleRole={Gtk.AccessibleRole.DOCUMENT}
                    accessibleLabel={t("Preview")}
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={12}
                    marginTop={18}
                    marginBottom={18}
                    marginStart={18}
                    marginEnd={18}
                >
                    <LinkContext value={onActivateLink}>
                        <Blocks nodes={tree.children} />
                    </LinkContext>
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

export { MarkdownPreview };
  • CodeBlock puts the label in a box with the card style class, which gives it the rounded background of a boxed list. The margins keep the code clear of the card's edge, and hexpand stretches the label across the card.
  • QuoteBlock sets a vertical GtkSeparator beside the quote's blocks, as a bar. The module already imports GtkSeparator for thematic breaks.

When the app restarts ​

Some edits can't be applied to the running app, so gtkx dev relaunches it, and its state starts over: no note is open and the preview is off. The most common one is a change to which modules a file imports. Importing a module the file didn't import before, or dropping one, prints Module imports changed: <path> and Full restart (process restart). Adding a name to an import the file already has, such as another element from @gtkx/jsx/gtk, refreshes as usual.

A component whose hook calls change, for example one that gains a useState, is mounted again with the components inside it, and they lose their state while the rest of the window keeps its own. The app restarts instead of refreshing lists every edit that restarts the app.

Run it ​

With npm run dev running, open Meeting notes and press the eye button in its header bar. Save each change to markdown-preview.tsx: the code block moves into a card, the quote gets its bar, and the note stays open in the preview.

The Notes window with Meeting notes shown as a rendered preview: a dimmed quote with a bar beside it and a code block with two npm commands in a card.The Notes window with Meeting notes shown as a rendered preview: a dimmed quote with a bar beside it and a code block with two npm commands in a card.
Captured from src/preview/markdown-preview.tsx above, after opening Meeting notes and turning on the preview.