mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: code block rendering, syntax highlighting, and compose fixes (#803)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ Thumbs.db
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
.playwright-mcp/
|
||||
|
||||
# Node modules (pnpm workspace hoists to root)
|
||||
node_modules/
|
||||
|
||||
@@ -23,6 +23,7 @@ Run `sprout --help` or `sprout <group> --help` for full usage.
|
||||
## Communication Patterns
|
||||
|
||||
- Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery).
|
||||
- Message content supports GitHub-flavored Markdown. Use fenced code blocks with a language tag (` ```python `, ` ```typescript `, etc.) for syntax-highlighted rendering on desktop and mobile. Omitting the language tag renders monochrome.
|
||||
- Use `sprout messages thread` when responding in-thread; post new messages for new topics.
|
||||
- No push notifications — poll with `sprout messages get --channel <UUID> --since <ts>`. When `since` is set without `before`, results are oldest-first (chronological).
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@ async fn resolve_content_mentions(
|
||||
pub struct SendMessageParams {
|
||||
/// UUID of the channel to post to.
|
||||
pub channel_id: String,
|
||||
/// Message body text.
|
||||
/// Message body text. Supports GitHub-flavored Markdown including fenced code
|
||||
/// blocks with syntax highlighting.
|
||||
pub content: String,
|
||||
/// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message).
|
||||
#[serde(default = "default_kind")]
|
||||
@@ -890,7 +891,9 @@ impl SproutMcpServer {
|
||||
/// Send a message to a Sprout channel.
|
||||
#[tool(
|
||||
name = "send_message",
|
||||
description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \
|
||||
description = "Send a message to a Sprout channel. Content supports GitHub-flavored Markdown — \
|
||||
use fenced code blocks with a language tag for syntax-highlighted rendering. \
|
||||
Include `parent_event_id` to reply in a thread. \
|
||||
Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \
|
||||
For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \
|
||||
Default kind is 9 (stream message)."
|
||||
|
||||
@@ -74,6 +74,15 @@ Write commands are unaffected. `--format json` (default) returns full fields.
|
||||
|
||||
Other kind values are rejected. Use `messages vote --event <id> --direction up|down` to vote on forum posts.
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Message content is rendered as GitHub-flavored Markdown on both desktop and mobile. Key formatting:
|
||||
|
||||
- **Fenced code blocks**: triple-backtick with a language tag for syntax highlighting (190+ languages supported). Omitting the language tag renders a styled monochrome block.
|
||||
- **Inline code**: single backticks for inline monospace.
|
||||
- **Mentions**: plain `@name` — do NOT bold or italicize (formatting prevents alert delivery).
|
||||
- **Links, images, tables, blockquotes, headings**: standard GFM.
|
||||
|
||||
## Mem Patch Workflow
|
||||
|
||||
For safe concurrent writes, use hash-based conflict detection:
|
||||
|
||||
@@ -23,7 +23,20 @@ export function handleCodeFenceEnter(ed: Editor): boolean | undefined {
|
||||
"",
|
||||
);
|
||||
|
||||
if (FENCE_AT_START.test(textBefore)) return false;
|
||||
const startMatch = textBefore.match(FENCE_AT_START);
|
||||
if (startMatch) {
|
||||
const { tr, schema } = ed.state;
|
||||
const attrs = startMatch[1] ? { language: startMatch[1] } : {};
|
||||
tr.delete($cursor.start(), $cursor.pos);
|
||||
tr.setBlockType(
|
||||
tr.mapping.map($cursor.start()),
|
||||
tr.mapping.map($cursor.start()),
|
||||
schema.nodes.codeBlock,
|
||||
attrs,
|
||||
);
|
||||
ed.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
const m = textBefore.match(FENCE_AFTER_BREAK);
|
||||
if (!m) return undefined;
|
||||
|
||||
@@ -173,6 +173,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Code block line numbers & diff ────────────────────────────────── */
|
||||
.code-block-lines {
|
||||
counter-reset: code-line;
|
||||
}
|
||||
|
||||
.code-block-lines [data-line] {
|
||||
display: block;
|
||||
counter-increment: code-line;
|
||||
}
|
||||
|
||||
.code-block-lines [data-line]::before {
|
||||
content: counter(code-line);
|
||||
display: inline-block;
|
||||
width: 2.5ch;
|
||||
margin-right: 1.5ch;
|
||||
text-align: right;
|
||||
color: hsl(var(--muted-foreground) / 0.4);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-line-diff-add {
|
||||
background-color: hsl(120 40% 50% / 0.15);
|
||||
}
|
||||
|
||||
.code-line-diff-remove {
|
||||
background-color: hsl(0 60% 50% / 0.15);
|
||||
}
|
||||
|
||||
.dark .code-line-diff-add {
|
||||
background-color: hsl(120 40% 50% / 0.12);
|
||||
}
|
||||
|
||||
.dark .code-line-diff-remove {
|
||||
background-color: hsl(0 60% 50% / 0.12);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Catppuccin Latte (mauve accent) */
|
||||
|
||||
@@ -9,6 +9,15 @@ import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSingletonHighlighter,
|
||||
type HighlighterGeneric,
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
type ThemedToken,
|
||||
} from "shiki";
|
||||
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import {
|
||||
isMessageLink,
|
||||
@@ -52,6 +61,59 @@ type ImetaEntry = {
|
||||
|
||||
type ImetaLookup = Map<string, ImetaEntry>;
|
||||
|
||||
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
|
||||
null;
|
||||
let shikiInitPromise: Promise<void> | null = null;
|
||||
const loadedLangs = new Set<string>();
|
||||
const loadedThemes = new Set<string>();
|
||||
const tokenCache = new Map<string, ThemedToken[][]>();
|
||||
const MAX_CACHE_ENTRIES = 100;
|
||||
const MAX_LOADED_LANGUAGES = 30;
|
||||
const MAX_HIGHLIGHT_LINES = 150;
|
||||
const CODE_BLOCK_CLASS =
|
||||
"code-block-lines block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground";
|
||||
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
|
||||
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
|
||||
|
||||
function ensureHighlighter(): Promise<void> {
|
||||
if (shikiHighlighter) return Promise.resolve();
|
||||
if (!shikiInitPromise) {
|
||||
shikiInitPromise = getSingletonHighlighter({
|
||||
themes: [],
|
||||
langs: [],
|
||||
}).then((h) => {
|
||||
shikiHighlighter = h;
|
||||
});
|
||||
}
|
||||
return shikiInitPromise;
|
||||
}
|
||||
|
||||
function extractLanguage(className?: string): string {
|
||||
if (typeof className !== "string") return "";
|
||||
const match = className.match(/language-(\S+)/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function stripDiffMarker(tokens: ThemedToken[], marker: RegExp): ThemedToken[] {
|
||||
const last = tokens[tokens.length - 1];
|
||||
if (!last) return tokens;
|
||||
const stripped = last.content.replace(marker, "");
|
||||
if (stripped === last.content) return tokens;
|
||||
if (stripped === "") return tokens.slice(0, -1);
|
||||
return [...tokens.slice(0, -1), { ...last, content: stripped }];
|
||||
}
|
||||
|
||||
function useStableArray<T>(arr: T[]): T[] {
|
||||
const ref = React.useRef(arr);
|
||||
if (
|
||||
arr.length !== ref.current.length ||
|
||||
arr.some((item, i) => item !== ref.current[i])
|
||||
) {
|
||||
ref.current = arr;
|
||||
}
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* `urlTransform` for `<ReactMarkdown>` that preserves `sprout://message?…`
|
||||
* links. The default transform strips unknown schemes (returns `""`) before
|
||||
@@ -162,7 +224,13 @@ function getCodeBlockText(children: React.ReactNode) {
|
||||
return getReactNodeText(children).replace(/\n$/, "");
|
||||
}
|
||||
|
||||
function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
|
||||
function MarkdownCodeBlock({
|
||||
children,
|
||||
language,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
language?: string;
|
||||
}) {
|
||||
const [isCopying, setIsCopying] = React.useState(false);
|
||||
const code = React.useMemo(() => getCodeBlockText(children), [children]);
|
||||
|
||||
@@ -187,7 +255,12 @@ function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="group relative" data-code-block="">
|
||||
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
|
||||
<pre className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
|
||||
{language && (
|
||||
<div className="mb-1 text-xs text-muted-foreground/70">
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</pre>
|
||||
<Tooltip>
|
||||
@@ -265,6 +338,139 @@ function FileCard({
|
||||
);
|
||||
}
|
||||
|
||||
function SyntaxHighlightedCode({
|
||||
code,
|
||||
language,
|
||||
...props
|
||||
}: {
|
||||
code: string;
|
||||
language: string;
|
||||
} & React.ComponentProps<"code">) {
|
||||
const { themeName } = useTheme();
|
||||
const [loadedKey, setLoadedKey] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadAssets() {
|
||||
try {
|
||||
await ensureHighlighter();
|
||||
if (!shikiHighlighter || cancelled) return;
|
||||
let loaded = false;
|
||||
if (!loadedLangs.has(language)) {
|
||||
if (loadedLangs.size >= MAX_LOADED_LANGUAGES) return;
|
||||
try {
|
||||
await shikiHighlighter.loadLanguage(language as BundledLanguage);
|
||||
loadedLangs.add(language);
|
||||
loaded = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!loadedThemes.has(themeName as string)) {
|
||||
try {
|
||||
await shikiHighlighter.loadTheme(themeName as BundledTheme);
|
||||
loadedThemes.add(themeName as string);
|
||||
loaded = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (loaded && !cancelled) setLoadedKey((k) => k + 1);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!loadedLangs.has(language) || !loadedThemes.has(themeName as string)) {
|
||||
loadAssets();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [language, themeName]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading
|
||||
const tokens = React.useMemo(() => {
|
||||
if (
|
||||
!shikiHighlighter ||
|
||||
!loadedLangs.has(language) ||
|
||||
!loadedThemes.has(themeName as string)
|
||||
)
|
||||
return null;
|
||||
if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null;
|
||||
const cacheKey = `${language}:${themeName}:${code}`;
|
||||
const cached = tokenCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const result = shikiHighlighter.codeToTokens(code, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: themeName as BundledTheme,
|
||||
});
|
||||
if (tokenCache.size >= MAX_CACHE_ENTRIES) {
|
||||
const firstKey = tokenCache.keys().next().value;
|
||||
if (firstKey !== undefined) tokenCache.delete(firstKey);
|
||||
}
|
||||
tokenCache.set(cacheKey, result.tokens);
|
||||
return result.tokens;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [code, language, themeName, loadedKey]);
|
||||
|
||||
const codeClassName = CODE_BLOCK_CLASS;
|
||||
|
||||
if (!tokens) {
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
<code {...props} className={codeClassName}>
|
||||
{lines.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
|
||||
<span key={i} data-line="">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code {...props} className={codeClassName}>
|
||||
{tokens.map((line, lineIdx) => {
|
||||
const lineText = line.map((t) => t.content).join("");
|
||||
const isAdd = DIFF_ADD_RE.test(lineText);
|
||||
const isRemove = DIFF_REMOVE_RE.test(lineText);
|
||||
const diffClass = isAdd
|
||||
? "code-line-diff-add"
|
||||
: isRemove
|
||||
? "code-line-diff-remove"
|
||||
: undefined;
|
||||
|
||||
const renderedTokens =
|
||||
isAdd || isRemove
|
||||
? stripDiffMarker(line, isAdd ? DIFF_ADD_RE : DIFF_REMOVE_RE)
|
||||
: line;
|
||||
|
||||
return (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
||||
key={lineIdx}
|
||||
data-line=""
|
||||
className={diffClass}
|
||||
>
|
||||
{renderedTokens.map((token, tokenIdx) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
||||
key={tokenIdx}
|
||||
style={token.color ? { color: token.color } : undefined}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
function createMarkdownComponents(
|
||||
variant: MarkdownVariant,
|
||||
channels: Channel[],
|
||||
@@ -360,15 +566,23 @@ function createMarkdownComponents(
|
||||
typeof className === "string" && className.includes("language-");
|
||||
|
||||
if (isFencedCodeBlock || rawCode.endsWith("\n") || code.includes("\n")) {
|
||||
const language = extractLanguage(className);
|
||||
|
||||
if (language) {
|
||||
return (
|
||||
<SyntaxHighlightedCode code={code} language={language} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className={cn(
|
||||
"block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
<code {...props} className={CODE_BLOCK_CLASS}>
|
||||
{lines.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
|
||||
<span key={i} data-line="">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -521,12 +735,21 @@ function createMarkdownComponents(
|
||||
|
||||
return <p className={paragraphClassName}>{children}</p>;
|
||||
},
|
||||
pre: ({ children }) =>
|
||||
interactive ? (
|
||||
<MarkdownCodeBlock>{children}</MarkdownCodeBlock>
|
||||
) : (
|
||||
<span>{children}</span>
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
if (!interactive) return <span>{children}</span>;
|
||||
let language = "";
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (
|
||||
React.isValidElement<Record<string, unknown>>(child) &&
|
||||
typeof child.props?.className === "string"
|
||||
) {
|
||||
language = extractLanguage(child.props.className);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<MarkdownCodeBlock language={language}>{children}</MarkdownCodeBlock>
|
||||
);
|
||||
},
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold">{children}</strong>
|
||||
),
|
||||
@@ -687,7 +910,8 @@ function MarkdownInner({
|
||||
: compact
|
||||
? "compact"
|
||||
: "default";
|
||||
const { channels } = useChannelNavigation();
|
||||
const { channels: rawChannels } = useChannelNavigation();
|
||||
const channels = useStableArray(rawChannels);
|
||||
const { goChannel } = useAppNavigation();
|
||||
|
||||
const components = React.useMemo(
|
||||
|
||||
@@ -71,12 +71,12 @@ test("copy a rendered code block and paste it back as code", async ({
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const copiedCodeBlock = page.locator("pre", { hasText: code });
|
||||
await expect(copiedCodeBlock).toHaveCount(1);
|
||||
const codeBlock = page.locator("[data-code-block]");
|
||||
await expect(codeBlock).toHaveCount(1);
|
||||
|
||||
const copyButton = page.getByLabel("Copy code block");
|
||||
await expect(copyButton).toHaveCSS("opacity", "0");
|
||||
await copiedCodeBlock.hover();
|
||||
await codeBlock.hover();
|
||||
await expect(copyButton).toHaveCSS("opacity", "1");
|
||||
await copyButton.click();
|
||||
await expect
|
||||
@@ -87,7 +87,7 @@ test("copy a rendered code block and paste it back as code", async ({
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(copiedCodeBlock).toHaveCount(2);
|
||||
await expect(codeBlock).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("pasting a long copied code block scrolls composer to cursor", async ({
|
||||
@@ -115,7 +115,7 @@ test("pasting a long copied code block scrolls composer to cursor", async ({
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const copiedCodeBlock = page.locator("pre", { hasText: longCode });
|
||||
const copiedCodeBlock = page.locator("[data-code-block]");
|
||||
await expect(copiedCodeBlock).toHaveCount(1);
|
||||
await copiedCodeBlock.hover();
|
||||
await page.getByLabel("Copy code block").click();
|
||||
@@ -134,6 +134,52 @@ test("pasting a long copied code block scrolls composer to cursor", async ({
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("code block shows language label when language is specified", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: "http://127.0.0.1:4173",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await page.evaluate(
|
||||
(text) => navigator.clipboard.writeText(text),
|
||||
"```typescript\nconst x = 1;\n```",
|
||||
);
|
||||
|
||||
await input.click();
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const codeBlock = page.locator("[data-code-block]");
|
||||
await expect(codeBlock).toBeVisible();
|
||||
await expect(codeBlock.getByText("typescript")).toBeVisible();
|
||||
});
|
||||
|
||||
test("typing triple backticks and Enter creates a code block in composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await page.keyboard.type("```");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// A <pre> code block should appear inside the ProseMirror editor
|
||||
const editorPre = input.locator("pre");
|
||||
await expect(editorPre).toBeVisible();
|
||||
|
||||
// The literal backticks should be consumed (not visible as text)
|
||||
await expect(input).not.toContainText("```");
|
||||
});
|
||||
|
||||
test("message input clears after send", async ({ page }) => {
|
||||
const message = `Clear after send ${Date.now()}`;
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
@@ -89,10 +89,12 @@ class ComposeBar extends HookConsumerWidget {
|
||||
|
||||
// Typing indicator broadcast — throttled to one event per 3 seconds.
|
||||
final lastTypingSentMs = useRef(0);
|
||||
final isModifyingText = useRef(false);
|
||||
|
||||
// Detect @mention query and broadcast typing on text / selection change.
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
if (isModifyingText.value) return;
|
||||
final text = controller.text;
|
||||
final sel = controller.selection;
|
||||
|
||||
@@ -291,51 +293,27 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final sel = controller.selection;
|
||||
if (!sel.isValid) return;
|
||||
|
||||
if (sel.isCollapsed) {
|
||||
final offset = sel.baseOffset;
|
||||
final updated =
|
||||
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: offset + prefix.length,
|
||||
);
|
||||
} else {
|
||||
final selected = text.substring(sel.start, sel.end);
|
||||
final updated =
|
||||
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: sel.start + prefix.length + selected.length + suffix.length,
|
||||
);
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void applyCodeBlock() {
|
||||
final text = controller.text;
|
||||
final sel = controller.selection;
|
||||
if (!sel.isValid) return;
|
||||
|
||||
if (sel.isCollapsed) {
|
||||
final offset = sel.baseOffset;
|
||||
const open = '```\n';
|
||||
const close = '\n```';
|
||||
final updated =
|
||||
'${text.substring(0, offset)}$open$close${text.substring(offset)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: offset + open.length,
|
||||
);
|
||||
} else {
|
||||
final selected = text.substring(sel.start, sel.end);
|
||||
const open = '```\n';
|
||||
const close = '\n```';
|
||||
final updated =
|
||||
'${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: sel.start + open.length + selected.length + close.length,
|
||||
);
|
||||
isModifyingText.value = true;
|
||||
try {
|
||||
if (sel.isCollapsed) {
|
||||
final offset = sel.baseOffset;
|
||||
final updated =
|
||||
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: offset + prefix.length,
|
||||
);
|
||||
} else {
|
||||
final selected = text.substring(sel.start, sel.end);
|
||||
final updated =
|
||||
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
|
||||
controller.text = updated;
|
||||
controller.selection = TextSelection.collapsed(
|
||||
offset: sel.start + prefix.length + selected.length + suffix.length,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isModifyingText.value = false;
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
@@ -394,10 +372,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
children: [
|
||||
// Formatting toolbar (toggled via Aa button).
|
||||
if (showFormatting.value)
|
||||
_FormattingToolbar(
|
||||
onFormat: applyFormat,
|
||||
onCodeBlock: applyCodeBlock,
|
||||
),
|
||||
_FormattingToolbar(onFormat: applyFormat),
|
||||
|
||||
if (hasAttachments || hasPendingUploads) ...[
|
||||
_AttachmentStrip(
|
||||
@@ -852,9 +827,8 @@ class _ChannelSuggestions extends StatelessWidget {
|
||||
|
||||
class _FormattingToolbar extends StatelessWidget {
|
||||
final void Function(String prefix, [String? suffix]) onFormat;
|
||||
final VoidCallback onCodeBlock;
|
||||
|
||||
const _FormattingToolbar({required this.onFormat, required this.onCodeBlock});
|
||||
const _FormattingToolbar({required this.onFormat});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -885,7 +859,7 @@ class _FormattingToolbar extends StatelessWidget {
|
||||
_FormatButton(
|
||||
icon: LucideIcons.squareCode,
|
||||
tooltip: 'Code block',
|
||||
onTap: onCodeBlock,
|
||||
onTap: () => onFormat('```\n', '\n```'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/syntax_highlight.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'media_viewer_page.dart';
|
||||
import 'message_media.dart';
|
||||
@@ -15,7 +18,7 @@ const _messageMediaMaxImageHeight = 240.0;
|
||||
|
||||
/// Renders message content with markdown formatting, @mentions, #channel links,
|
||||
/// and media-aware markdown images/videos.
|
||||
class MessageContent extends StatelessWidget {
|
||||
class MessageContent extends HookWidget {
|
||||
final String content;
|
||||
|
||||
/// Display names for mentioned pubkeys, extracted from event p-tags.
|
||||
@@ -51,75 +54,80 @@ class MessageContent extends StatelessWidget {
|
||||
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
||||
final imetaByUrl = parseImetaTags(tags);
|
||||
|
||||
// Convert autolinks and bare URLs to standard markdown links,
|
||||
// but skip content inside backticks (inline code / fenced blocks).
|
||||
final buffer = StringBuffer();
|
||||
final parts = content.split('`');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
// Inside backticks — preserve as-is.
|
||||
buffer.write('`${parts[i]}`');
|
||||
} else {
|
||||
// 1. Angle-bracket autolinks: <https://...>
|
||||
var segment = parts[i].replaceAllMapped(
|
||||
RegExp(r'<(https?://[^>]+)>'),
|
||||
(m) => '[${m[1]}](${m[1]})',
|
||||
);
|
||||
// 2. Bare URLs not already inside markdown link/image syntax.
|
||||
// Negative lookbehind avoids matching URLs preceded by ]( or =
|
||||
// which are already part of markdown links or imeta tags.
|
||||
segment = segment.replaceAllMapped(
|
||||
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
|
||||
(m) {
|
||||
final url = m[0]!;
|
||||
// Skip if this URL is already a markdown link label that equals
|
||||
// the URL (produced by step 1 or authored as [url](url)).
|
||||
final start = m.start;
|
||||
if (start >= 1 && segment[start - 1] == '[') return url;
|
||||
return '[$url]($url)';
|
||||
},
|
||||
);
|
||||
buffer.write(segment);
|
||||
}
|
||||
}
|
||||
final processed = buffer.toString();
|
||||
|
||||
// Replace spaces with non-breaking spaces inside known mention names
|
||||
// so the gpt_markdown combined regex can match multi-word names
|
||||
// even when caseSensitive is not preserved.
|
||||
// Skip content inside backticks to avoid altering inline code.
|
||||
final mentionParts = processed.split('`');
|
||||
final mentionBuf = StringBuffer();
|
||||
for (var i = 0; i < mentionParts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
mentionBuf.write('`${mentionParts[i]}`');
|
||||
} else {
|
||||
var segment = mentionParts[i];
|
||||
for (final name in mentionNames.values) {
|
||||
if (name.contains(' ')) {
|
||||
final nbspName = name.replaceAll(' ', '\u00A0');
|
||||
segment = segment.replaceAllMapped(
|
||||
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
|
||||
(m) => '@$nbspName',
|
||||
);
|
||||
}
|
||||
final finalContent = useMemoized(() {
|
||||
// Convert autolinks and bare URLs to standard markdown links,
|
||||
// but skip content inside backticks (inline code / fenced blocks).
|
||||
final buffer = StringBuffer();
|
||||
final parts = content.split('`');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
// Inside backticks — preserve as-is.
|
||||
buffer.write('`${parts[i]}`');
|
||||
} else {
|
||||
// 1. Angle-bracket autolinks: <https://...>
|
||||
var segment = parts[i].replaceAllMapped(
|
||||
RegExp(r'<(https?://[^>]+)>'),
|
||||
(m) => '[${m[1]}](${m[1]})',
|
||||
);
|
||||
// 2. Bare URLs not already inside markdown link/image syntax.
|
||||
// Negative lookbehind avoids matching URLs preceded by ]( or =
|
||||
// which are already part of markdown links or imeta tags.
|
||||
segment = segment.replaceAllMapped(
|
||||
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
|
||||
(m) {
|
||||
final url = m[0]!;
|
||||
// Skip if this URL is already a markdown link label that equals
|
||||
// the URL (produced by step 1 or authored as [url](url)).
|
||||
final start = m.start;
|
||||
if (start >= 1 && segment[start - 1] == '[') return url;
|
||||
return '[$url]($url)';
|
||||
},
|
||||
);
|
||||
buffer.write(segment);
|
||||
}
|
||||
mentionBuf.write(segment);
|
||||
}
|
||||
}
|
||||
final mentionProcessed = mentionBuf.toString();
|
||||
final processed = buffer.toString();
|
||||
|
||||
// Ensure channel links at the very start of content don't get
|
||||
// swallowed by markdown processing.
|
||||
var finalContent = mentionProcessed;
|
||||
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(finalContent)) {
|
||||
finalContent = '\u200B$finalContent';
|
||||
}
|
||||
// Replace spaces with non-breaking spaces inside known mention names
|
||||
// so the gpt_markdown combined regex can match multi-word names
|
||||
// even when caseSensitive is not preserved.
|
||||
// Skip content inside backticks to avoid altering inline code.
|
||||
final mentionParts = processed.split('`');
|
||||
final mentionBuf = StringBuffer();
|
||||
for (var i = 0; i < mentionParts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
mentionBuf.write('`${mentionParts[i]}`');
|
||||
} else {
|
||||
var segment = mentionParts[i];
|
||||
for (final name in mentionNames.values) {
|
||||
if (name.contains(' ')) {
|
||||
final nbspName = name.replaceAll(' ', '\u00A0');
|
||||
segment = segment.replaceAllMapped(
|
||||
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
|
||||
(m) => '@$nbspName',
|
||||
);
|
||||
}
|
||||
}
|
||||
mentionBuf.write(segment);
|
||||
}
|
||||
}
|
||||
final mentionProcessed = mentionBuf.toString();
|
||||
|
||||
// Ensure channel links at the very start of content don't get
|
||||
// swallowed by markdown processing.
|
||||
var result = mentionProcessed;
|
||||
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
|
||||
result = '\u200B$result';
|
||||
}
|
||||
return result;
|
||||
}, [content, mentionNames]);
|
||||
|
||||
return GptMarkdown(
|
||||
finalContent,
|
||||
style: style,
|
||||
followLinkColor: false,
|
||||
codeBuilder: (context, name, code, closed) =>
|
||||
_MessageCodeBlock(name: name, code: code),
|
||||
linkBuilder: (context, linkText, url, linkStyle) =>
|
||||
_buildLink(context, linkText, url, linkStyle, style),
|
||||
imageBuilder: (context, imageUrl) =>
|
||||
@@ -443,6 +451,111 @@ class _MediaPreviewFallback extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageCodeBlock extends HookWidget {
|
||||
final String name;
|
||||
final String code;
|
||||
|
||||
const _MessageCodeBlock({required this.name, required this.code});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCopied = useState(false);
|
||||
|
||||
Future<void> handleCopy() async {
|
||||
await copyToClipboard(context, code, message: 'Copied code to clipboard');
|
||||
if (!context.mounted) return;
|
||||
isCopied.value = true;
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (context.mounted) isCopied.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
final codeBaseStyle = TextStyle(
|
||||
fontFamily: 'GeistMono',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: context.colors.onSurface,
|
||||
);
|
||||
final isDark = context.theme.brightness == Brightness.dark;
|
||||
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
|
||||
final codeSpans = useMemoized(
|
||||
() => highlightCode(code, name, codeTheme, codeBaseStyle),
|
||||
[code, name, isDark],
|
||||
);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: Grid.half),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: context.colors.outline.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (name.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.twelve,
|
||||
top: Grid.half + Grid.quarter,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.twelve,
|
||||
name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
|
||||
44,
|
||||
Grid.half + Grid.quarter,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: RichText(
|
||||
softWrap: false,
|
||||
text: TextSpan(style: codeBaseStyle, children: codeSpans),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: Grid.quarter,
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
onPressed: handleCopy,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Icon(
|
||||
isCopied.value ? LucideIcons.check : LucideIcons.copy,
|
||||
size: 14,
|
||||
color: isCopied.value
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MentionMd extends InlineMd {
|
||||
final Map<String, String> mentionNames;
|
||||
late final RegExp _exp = _buildPrefixPattern(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
@@ -173,13 +173,11 @@ class UserProfileSheet extends HookConsumerWidget {
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: pubkey));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Public key copied'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
onTap: () async {
|
||||
await copyToClipboard(
|
||||
context,
|
||||
pubkey,
|
||||
message: 'Public key copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
@@ -182,10 +182,11 @@ class NoteCard extends HookConsumerWidget {
|
||||
),
|
||||
_ActionButton(
|
||||
icon: LucideIcons.share,
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: _shareUri(note)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied note URI')),
|
||||
onTap: () async {
|
||||
await copyToClipboard(
|
||||
context,
|
||||
_shareUri(note),
|
||||
message: 'Copied note URI',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -7,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
@@ -76,13 +76,11 @@ class SettingsPage extends HookConsumerWidget {
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.copy, size: 16),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: pubkey));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Pubkey copied'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
onPressed: () async {
|
||||
await copyToClipboard(
|
||||
context,
|
||||
pubkey,
|
||||
message: 'Pubkey copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
14
mobile/lib/shared/clipboard_utils.dart
Normal file
14
mobile/lib/shared/clipboard_utils.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<void> copyToClipboard(
|
||||
BuildContext context,
|
||||
String text, {
|
||||
String message = 'Copied to clipboard',
|
||||
}) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
107
mobile/lib/shared/syntax_highlight.dart
Normal file
107
mobile/lib/shared/syntax_highlight.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight, Node;
|
||||
|
||||
const highlightLightTheme = <String, TextStyle>{
|
||||
'keyword': TextStyle(color: Color(0xFFa626a4)),
|
||||
'built_in': TextStyle(color: Color(0xFF0184bc)),
|
||||
'type': TextStyle(color: Color(0xFF0184bc)),
|
||||
'literal': TextStyle(color: Color(0xFF0184bc)),
|
||||
'number': TextStyle(color: Color(0xFF986801)),
|
||||
'string': TextStyle(color: Color(0xFF50a14f)),
|
||||
'symbol': TextStyle(color: Color(0xFF50a14f)),
|
||||
'comment': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
|
||||
'doctag': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
|
||||
'meta': TextStyle(color: Color(0xFF986801)),
|
||||
'attr': TextStyle(color: Color(0xFF986801)),
|
||||
'attribute': TextStyle(color: Color(0xFF986801)),
|
||||
'title': TextStyle(color: Color(0xFF4078f2)),
|
||||
'title.class_': TextStyle(color: Color(0xFFc18401)),
|
||||
'title.function_': TextStyle(color: Color(0xFF4078f2)),
|
||||
'name': TextStyle(color: Color(0xFFe45649)),
|
||||
'tag': TextStyle(color: Color(0xFFe45649)),
|
||||
'selector-tag': TextStyle(color: Color(0xFFe45649)),
|
||||
'params': TextStyle(color: Color(0xFF383a42)),
|
||||
'variable': TextStyle(color: Color(0xFFe45649)),
|
||||
'subst': TextStyle(color: Color(0xFFe45649)),
|
||||
'section': TextStyle(color: Color(0xFF4078f2)),
|
||||
'bullet': TextStyle(color: Color(0xFF4078f2)),
|
||||
'link': TextStyle(color: Color(0xFF4078f2)),
|
||||
'addition': TextStyle(color: Color(0xFF50a14f)),
|
||||
'deletion': TextStyle(color: Color(0xFFe45649)),
|
||||
};
|
||||
|
||||
const highlightDarkTheme = <String, TextStyle>{
|
||||
'keyword': TextStyle(color: Color(0xFFc678dd)),
|
||||
'built_in': TextStyle(color: Color(0xFF56b6c2)),
|
||||
'type': TextStyle(color: Color(0xFF56b6c2)),
|
||||
'literal': TextStyle(color: Color(0xFF56b6c2)),
|
||||
'number': TextStyle(color: Color(0xFFd19a66)),
|
||||
'string': TextStyle(color: Color(0xFF98c379)),
|
||||
'symbol': TextStyle(color: Color(0xFF98c379)),
|
||||
'comment': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
|
||||
'doctag': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
|
||||
'meta': TextStyle(color: Color(0xFFd19a66)),
|
||||
'attr': TextStyle(color: Color(0xFFd19a66)),
|
||||
'attribute': TextStyle(color: Color(0xFFd19a66)),
|
||||
'title': TextStyle(color: Color(0xFF61afef)),
|
||||
'title.class_': TextStyle(color: Color(0xFFe5c07b)),
|
||||
'title.function_': TextStyle(color: Color(0xFF61afef)),
|
||||
'name': TextStyle(color: Color(0xFFe06c75)),
|
||||
'tag': TextStyle(color: Color(0xFFe06c75)),
|
||||
'selector-tag': TextStyle(color: Color(0xFFe06c75)),
|
||||
'params': TextStyle(color: Color(0xFFabb2bf)),
|
||||
'variable': TextStyle(color: Color(0xFFe06c75)),
|
||||
'subst': TextStyle(color: Color(0xFFe06c75)),
|
||||
'section': TextStyle(color: Color(0xFF61afef)),
|
||||
'bullet': TextStyle(color: Color(0xFF61afef)),
|
||||
'link': TextStyle(color: Color(0xFF61afef)),
|
||||
'addition': TextStyle(color: Color(0xFF98c379)),
|
||||
'deletion': TextStyle(color: Color(0xFFe06c75)),
|
||||
};
|
||||
|
||||
List<InlineSpan> highlightCode(
|
||||
String code,
|
||||
String language,
|
||||
Map<String, TextStyle> theme,
|
||||
TextStyle baseStyle,
|
||||
) {
|
||||
try {
|
||||
if (language.isEmpty) return [TextSpan(text: code, style: baseStyle)];
|
||||
final result = highlight.parse(code, language: language);
|
||||
if (result.nodes == null) return [TextSpan(text: code, style: baseStyle)];
|
||||
return buildSpans(result.nodes!, theme, baseStyle);
|
||||
} catch (_) {
|
||||
return [TextSpan(text: code, style: baseStyle)];
|
||||
}
|
||||
}
|
||||
|
||||
List<InlineSpan> buildSpans(
|
||||
List<Node> nodes,
|
||||
Map<String, TextStyle> theme,
|
||||
TextStyle baseStyle, {
|
||||
int maxDepth = 10,
|
||||
}) {
|
||||
final spans = <InlineSpan>[];
|
||||
for (final node in nodes) {
|
||||
if (maxDepth <= 0) {
|
||||
if (node.value != null) {
|
||||
spans.add(TextSpan(text: node.value, style: baseStyle));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (node.children != null && node.children!.isNotEmpty) {
|
||||
final childStyle = node.className != null
|
||||
? baseStyle.merge(theme[node.className])
|
||||
: baseStyle;
|
||||
spans.addAll(
|
||||
buildSpans(node.children!, theme, childStyle, maxDepth: maxDepth - 1),
|
||||
);
|
||||
} else if (node.value != null) {
|
||||
final style = node.className != null
|
||||
? baseStyle.merge(theme[node.className])
|
||||
: baseStyle;
|
||||
spans.add(TextSpan(text: node.value, style: style));
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
@@ -472,6 +472,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.6"
|
||||
highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: highlight
|
||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies:
|
||||
pointycastle: ^4.0.0
|
||||
url_launcher: ^6.3.2
|
||||
gpt_markdown: ^1.1.6
|
||||
highlight: ^0.7.0
|
||||
intl: ^0.20.2
|
||||
uuid: ^4.5.1
|
||||
image_picker: ^1.1.2
|
||||
|
||||
@@ -254,6 +254,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(_findRich('void main() {}'), findsWidgets);
|
||||
expect(find.text('dart'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user