This commit is contained in:
Timothy Jaeryang Baek committed 2026-10-16 15:11:59 +04:00
1 parent 4ebdd4a75c
commit 15ac5163ea
3 files changed
+115 -19

No files matched your search

@@ -423,6 +423,8 @@
{#key selectedFile}
<MarkdownDocument
content={fileContent}
onSave={readOnly ? null : onSave}
bind:saving
id={`file-${selectedFile}`}
path={selectedFile ?? ''}
{baseUrl}
@@ -17,13 +17,17 @@
<script lang="ts">
import { getAllContexts, mount, unmount, setContext, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import DOMPurify from 'dompurify';
import { v4 as uuidv4 } from 'uuid';
import { downloadFileBlob } from '$lib/apis/terminal';
import MarkdownTokens from '../Messages/Markdown/MarkdownTokens.svelte';
import type { Token } from 'marked';
import type { Token, Tokens } from 'marked';
import { toast } from 'svelte-sonner';
export let content: string;
export let onSave: ((content: string) => Promise<void>) | null = null;
export let saving = false;
export let id: string;
export let path = '';
export let baseUrl = '';
@@ -95,8 +99,69 @@
release();
}
function markdownTasks(tokens: Token[]) {
const tasks: Tokens.ListItem[] = [];
documentMarkdown.walkTokens(tokens, (token) => {
if (token.type === 'list_item' && token.task) tasks.push(token as Tokens.ListItem);
});
return tasks;
}
function toggleMarkdownTask(content: string, index: number, checked: boolean) {
const tasks = markdownTasks(documentMarkdown.lexer(content));
if (!tasks[index]) throw new Error('Task no longer exists. Reload the file and try again.');
if (tasks[index].checked === checked) return content;
// Confirm the marker with the renderer's parser, so duplicates and code examples stay untouched.
for (const match of content.matchAll(/\[[ xX]\]/g)) {
const offset = match.index! + 1;
if ((content[offset].toLowerCase() === 'x') === checked) continue;
const updated = content.slice(0, offset) + (checked ? 'x' : ' ') + content.slice(offset + 1);
const next = markdownTasks(documentMarkdown.lexer(updated));
if (
next.length === tasks.length &&
next.every((task, i) => task.checked === (i === index ? checked : tasks[i].checked))
) {
return updated;
}
}
throw new Error('Could not locate the task in the file. Reload the file and try again.');
}
const taskEnabled = writable(false);
$: taskEnabled.set(!!onSave);
setContext('markdown', {
document: true,
taskEnabled,
onTaskClick: async ({
item,
checked,
input
}: {
item: Tokens.ListItem;
checked: boolean;
input: HTMLInputElement;
}) => {
input.checked = !!item.checked;
if (!onSave || saving) return;
const previous = content;
const wasChecked = !!item.checked;
saving = true;
try {
const index = markdownTasks(tokens).indexOf(item);
const updated = toggleMarkdownTask(content, index, checked);
// Keep the rendered document in place while saving this single marker.
parsedContent = content = updated;
item.checked = input.checked = checked;
await onSave(updated);
} catch (error) {
parsedContent = content = previous;
item.checked = input.checked = wasChecked;
toast.error(String(error));
} finally {
saving = false;
}
},
allowRemoteContent: true,
lexer: (source: string) => documentMarkdown.lexer(source),
parseInline: (tokens: Token[]) =>
@@ -142,7 +207,12 @@
update(props);
return { update, destroy };
}
$: tokens = documentMarkdown.lexer(content);
let parsedContent: string | undefined;
let tokens: Token[] = [];
$: if (content !== parsedContent) {
tokens = documentMarkdown.lexer(content);
parsedContent = content;
}
const resolveHtmlImages = (node: HTMLElement) => {
let alive = true;
const update = () => {
@@ -261,7 +331,7 @@
.document-markdown :global(.codespan) {
font-size: 0.95em;
}
.document-markdown :global(input[type='checkbox']) {
.document-markdown :global(input[type='checkbox']:disabled) {
cursor: default;
}
.document-markdown :global(.document-code-block) {
@@ -1,21 +1,30 @@
<script lang="ts">
import { decode } from 'html-entities';
import { onMount, getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import type { Action } from 'svelte/action';
const i18n = getContext('i18n');
const markdown = getContext<
| {
document?: boolean;
taskEnabled?: Readable<boolean>;
onTaskClick?: (event: {
item: Tokens.ListItem;
checked: boolean;
input: HTMLInputElement;
}) => void;
lexer?: (source: string) => Token[];
renderHtml?: Action<HTMLElement, { id: string; tokens: Token[]; top: boolean }>;
}
| undefined
>('markdown');
const taskEnabled = markdown?.taskEnabled;
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { type Token } from 'marked';
import { type Token, type Tokens } from 'marked';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
@@ -340,18 +349,19 @@
<li dir="auto" class="text-start">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1 [&:dir(rtl)]:translate-x-1 flex-shrink-0"
class="task-checkbox translate-y-[1px] -translate-x-1 [&:dir(rtl)]:translate-x-1 flex-shrink-0"
type="checkbox"
disabled={markdown?.document ?? false}
disabled={markdown?.document && !$taskEnabled}
checked={item.checked}
on:change={(e) => {
onTaskClick({
(markdown?.onTaskClick ?? onTaskClick)({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
input: e.currentTarget,
checked: e.currentTarget.checked
});
}}
/>
@@ -379,31 +389,27 @@
{:else}
<ul dir="auto" class="">
{#each token.items as item, itemIdx}
<li
dir="auto"
class="text-start {item?.task
? 'flex -translate-x-6.5 [&:dir(rtl)]:translate-x-6.5 gap-3 '
: ''}"
>
<li dir="auto" class="text-start {item?.task ? 'relative list-none' : ''}">
{#if item?.task}
<input
class="flex-shrink-0"
class="task-checkbox"
type="checkbox"
disabled={markdown?.document ?? false}
disabled={markdown?.document && !$taskEnabled}
checked={item.checked}
on:change={(e) => {
onTaskClick({
(markdown?.onTaskClick ?? onTaskClick)({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
input: e.currentTarget,
checked: e.currentTarget.checked
});
}}
/>
<div>
<div class="task-content">
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
{chatId}
@@ -673,4 +679,22 @@
li {
margin-inline-start: 1.5em;
}
ul > li > input.task-checkbox {
position: absolute;
inset-inline-start: -1.5em;
top: calc((1lh - 0.875rem) / 2);
margin: 0;
}
input.task-checkbox::after {
inset: 0;
width: auto;
height: auto;
background-size: 100% 100%;
}
.task-content > :global(p:first-child) {
margin-top: 0;
}
</style>