bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { MarkdownAstNode, MarkdownElementNode } from '~/utils/markdown';
|
||||
import { createMarkdownParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
|
||||
import { createTextVNode, h, ref, watch } from 'vue';
|
||||
import type { MarkdownAstNode } from '~/utils/markdown';
|
||||
import { agentParser, userParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
|
||||
import { createTextVNode, h, shallowRef, watch } from 'vue';
|
||||
import type { SearchSource } from '~/utils/tool-rendering';
|
||||
import SourceCitation from './SourceCitation.vue';
|
||||
import MarkdownMath from './Math.vue';
|
||||
import MarkdownShikiHighlight from './ShikiHighlight.vue';
|
||||
|
||||
@@ -9,15 +11,19 @@ const props = defineProps<{
|
||||
content: string;
|
||||
finished: boolean;
|
||||
id: string;
|
||||
sources?: ReadonlyMap<string, SearchSource>;
|
||||
citations?: boolean;
|
||||
}>();
|
||||
|
||||
const parser = createMarkdownParser();
|
||||
const parser = props.citations ? agentParser : userParser;
|
||||
const parse = async (content: string, finished: boolean) => {
|
||||
const tree = await parser(content, { streaming: !finished });
|
||||
// Copy-on-write: br-free trees return by reference with no allocation.
|
||||
return splitParagraphBreaks(tree);
|
||||
};
|
||||
|
||||
const parsed = ref(await parse(props.content, props.finished));
|
||||
// Parsed trees are replaced, never mutated. Avoid proxying every AST node.
|
||||
const parsed = shallowRef(await parse(props.content, props.finished));
|
||||
let parseVersion = 0;
|
||||
|
||||
watch(
|
||||
@@ -46,6 +52,11 @@ const renderNode = (node: MarkdownAstNode, path: string): any => {
|
||||
|
||||
const tag = getNodeTag(node);
|
||||
const attributes = getNodeAttributes(node);
|
||||
if (tag === 'source-cite') {
|
||||
const ids = typeof attributes.ids === "string" ? attributes.ids.split(",") : [];
|
||||
return h(SourceCitation, { key: path, ids, sources: props.sources });
|
||||
}
|
||||
|
||||
if (tag === 'pre') {
|
||||
const language = typeof attributes.language === 'string' ? attributes.language : 'text';
|
||||
return h(MarkdownShikiHighlight, {
|
||||
@@ -79,9 +90,7 @@ const render = () => {
|
||||
.map((node, index) => renderNode(node, `${props.id}.${index}`))
|
||||
.filter((child) => child !== null);
|
||||
|
||||
return h('div', { class: 'prose-wrapper' }, [
|
||||
h('article', { class: 'markdown-body' }, children),
|
||||
]);
|
||||
return h('article', { class: 'prose-wrapper markdown-body' }, children);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { type Grammar } from 'shiki';
|
||||
import { hashSync } from '~/utils/hash';
|
||||
const props = defineProps<{ code: string; language: string }>();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const MAX_LENGTH = 15000;
|
||||
|
||||
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
|
||||
@@ -76,8 +74,6 @@ onUnmounted(() => {
|
||||
if (copyTimeout) clearTimeout(copyTimeout);
|
||||
clear();
|
||||
});
|
||||
|
||||
console.log("shiki codeblock rendered in", Date.now() - start);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { SearchSource } from "~/utils/tool-rendering";
|
||||
import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/vue";
|
||||
import { computed, onBeforeUnmount, ref, useId } from "vue";
|
||||
import { getFaviconUrl } from "~/utils/tool-rendering";
|
||||
|
||||
const props = defineProps<{
|
||||
ids: string[];
|
||||
sources?: ReadonlyMap<string, SearchSource>;
|
||||
}>();
|
||||
|
||||
const entries = computed(() => [...new Set(props.ids)].map(id => ({ id, source: props.sources?.get(id) })));
|
||||
const firstSource = computed(() => entries.value.find(entry => entry.source)?.source);
|
||||
const label = computed(() => {
|
||||
const first = firstSource.value?.hostname ?? "Source unavailable";
|
||||
return entries.value.length > 1 ? `${first} +${entries.value.length - 1}` : first;
|
||||
});
|
||||
const popupId = useId();
|
||||
const trigger = ref<HTMLElement | null>(null);
|
||||
const preview = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const failedFavicons = ref(new Set<string>());
|
||||
const { floatingStyles, placement } = useFloating(trigger, preview, {
|
||||
open,
|
||||
strategy: "fixed",
|
||||
placement: "top-start",
|
||||
middleware: [offset(8), flip(), shift({ padding: 12 })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
transform: false,
|
||||
});
|
||||
|
||||
const transformOrigin = computed(() =>
|
||||
placement.value.startsWith("top")
|
||||
? "transform-origin-bottom-center"
|
||||
: "transform-origin-top-center"
|
||||
);
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pointerInside = false;
|
||||
|
||||
const cancelClose = () => {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
};
|
||||
|
||||
const showPreview = () => {
|
||||
cancelClose();
|
||||
open.value = true;
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
// Allow the pointer to cross the gap between the badge and the preview.
|
||||
closeTimer = setTimeout(() => {
|
||||
closeTimer = undefined;
|
||||
const focused = document.activeElement;
|
||||
if (!pointerInside && !trigger.value?.contains(focused) && !preview.value?.contains(focused)) {
|
||||
open.value = false;
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const onPointerEnter = (event: PointerEvent) => {
|
||||
if (event.pointerType === "touch") {
|
||||
return;
|
||||
}
|
||||
pointerInside = true;
|
||||
showPreview();
|
||||
};
|
||||
|
||||
const onPointerLeave = (event: PointerEvent) => {
|
||||
if (event.pointerType === "touch") {
|
||||
return;
|
||||
}
|
||||
pointerInside = false;
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
const dismissPreview = () => {
|
||||
cancelClose();
|
||||
if (preview.value?.contains(document.activeElement)) {
|
||||
trigger.value?.focus();
|
||||
}
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
onBeforeUnmount(cancelClose);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a ref="trigger" :href="firstSource?.url" target="_blank" rel="noopener noreferrer"
|
||||
:tabindex="firstSource ? undefined : 0" :aria-expanded="open" :aria-controls="open ? popupId : undefined"
|
||||
@pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @focusin="showPreview" @focusout="scheduleClose"
|
||||
@keydown.esc.stop.prevent="dismissPreview"
|
||||
class="inline-flex items-center gap-1 max-w-64 mx-1 px-2 border border-[var(--color-border)] rounded-full bg-[var(--bg-container)] hover:bg-[var(--color-hover)] transition-colors text-[var(--text-secondary)] text-xs align-baseline cursor-pointer focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--text-secondary)] focus-visible:outline-offset-2 decoration-none">
|
||||
<img v-if="firstSource && !failedFavicons.has(firstSource.hostname)" :src="getFaviconUrl(firstSource.hostname)"
|
||||
alt="" loading="lazy" class="rounded-[4px] w-[0.9rem] h-[0.9rem] m-0"
|
||||
@error="failedFavicons.add(firstSource.hostname)">
|
||||
{{ label }}
|
||||
</a>
|
||||
<Transition enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||
<span v-if="open" :id="popupId" ref="preview" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave"
|
||||
@focusin="showPreview" @focusout="scheduleClose" @keydown.esc.stop.prevent="dismissPreview"
|
||||
class="z-2 inset-auto m-0 p-[0.85rem] w-[min(22rem,calc(100vw_-_24px))] max-h-[min(24rem,70vh)] overflow-y-auto border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-md text-sm leading-normal"
|
||||
:class="transformOrigin" :style="floatingStyles">
|
||||
<span v-for="(entry, index) in entries" :key="entry.id"
|
||||
class="flex flex-col gap-[0.4rem] [overflow-wrap:anywhere]"
|
||||
:class="{ 'mt-[0.8rem] pt-[0.8rem] border-t border-[var(--color-border)]': index > 0 }">
|
||||
<template v-if="entry.source">
|
||||
<span class="text-xs text-[var(--text-secondary)]">{{ entry.source.hostname }} · Search
|
||||
excerpt</span>
|
||||
<a :href="entry.source.url" target="_blank" rel="noopener noreferrer">{{ entry.source.title }}</a>
|
||||
<span v-if="entry.source.snippet" class="whitespace-pre-wrap text-[var(--text-secondary)]">{{
|
||||
entry.source.snippet }}</span>
|
||||
</template>
|
||||
<span v-else>Source unavailable ({{ entry.id }})</span>
|
||||
</span>
|
||||
</span>
|
||||
</Transition>
|
||||
</template>
|
||||
Reference in New Issue
Block a user