From f1cd6b933d53af8381900ac523b8e78d7c792598 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 16 Dec 2024 16:04:07 -0800 Subject: [PATCH] relative markdown text (#1489) --- .vscode/settings.json | 3 + frontend/app/element/markdown-util.ts | 59 ++++++++++++ frontend/app/element/markdown.scss | 134 ++++++++++++++------------ frontend/app/element/markdown.tsx | 94 ++++++++++++------ frontend/app/theme.scss | 6 +- frontend/app/view/preview/preview.tsx | 10 +- frontend/app/view/waveai/waveai.scss | 3 - frontend/app/view/waveai/waveai.tsx | 14 +-- frontend/types/gotypes.d.ts | 4 + package.json | 1 + pkg/waveobj/metaconsts.go | 3 + pkg/waveobj/wtypemeta.go | 3 + pkg/wconfig/metaconsts.go | 3 + pkg/wconfig/settingsconfig.go | 3 + yarn.lock | 8 ++ 15 files changed, 246 insertions(+), 102 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d0b01c951..831ce237f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,9 @@ "[less]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/frontend/app/element/markdown-util.ts b/frontend/app/element/markdown-util.ts index 0892d8453..9bb6d8781 100644 --- a/frontend/app/element/markdown-util.ts +++ b/frontend/app/element/markdown-util.ts @@ -1,6 +1,12 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { isBlank, makeConnRoute } from "@/util/util"; +import parseSrcSet from "parse-srcset"; + export type MarkdownContentBlockType = { type: string; id: string; @@ -147,3 +153,56 @@ export function transformBlocks(content: string): { content: string; blocks: Map blocks: blocks, }; } + +export const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownResolveOpts): Promise => { + if (!filepath || filepath.startsWith("http://") || filepath.startsWith("https://")) { + return filepath; + } + + try { + const route = makeConnRoute(resolveOpts.connName); + const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, filepath], { + route: route, + }); + + const usp = new URLSearchParams(); + usp.set("path", fileInfo.path); + if (!isBlank(resolveOpts.connName)) { + usp.set("connection", resolveOpts.connName); + } + + return getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); + } catch (err) { + console.warn("Failed to resolve remote file:", filepath, err); + return null; + } +}; + +export const resolveSrcSet = async (srcSet: string, resolveOpts: MarkdownResolveOpts): Promise => { + if (!srcSet) return null; + + // Parse the srcset + const candidates = parseSrcSet(srcSet); + + // Resolve each URL in the array of candidates + const resolvedCandidates = await Promise.all( + candidates.map(async (candidate) => { + const resolvedUrl = await resolveRemoteFile(candidate.url, resolveOpts); + return { + ...candidate, + url: resolvedUrl, + }; + }) + ); + + // Reconstruct the srcset string + return resolvedCandidates + .map((candidate) => { + let part = candidate.url; + if (candidate.w) part += ` ${candidate.w}w`; + if (candidate.h) part += ` ${candidate.h}h`; + if (candidate.d) part += ` ${candidate.d}x`; + return part; + }) + .join(", "); +}; diff --git a/frontend/app/element/markdown.scss b/frontend/app/element/markdown.scss index b485806d3..4bee56855 100644 --- a/frontend/app/element/markdown.scss +++ b/frontend/app/element/markdown.scss @@ -16,21 +16,66 @@ overflow: scroll; line-height: 1.5; color: var(--main-text-color); - font-family: var(--markdown-font); - font-size: 14px; + font-family: var(--markdown-font-family); + font-size: var(--markdown-font-size); overflow-wrap: break-word; &.non-scrollable { overflow: hidden; } + .heading:not(.heading ~ .heading) { + margin-top: 0 !important; + } + .heading { - &:first-of-type { - margin-top: 0 !important; - } color: var(--main-text-color); - margin-top: 16px; - margin-bottom: 8px; + margin-top: 1.143em; + margin-bottom: 0.571em; + font-weight: semibold; + padding-top: 0.429em; + + &.is-1 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.429em; + font-size: 2em; + } + &.is-2 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.429em; + font-size: 1.5em; + } + &.is-3 { + font-size: 1.25em; + } + &.is-4 { + font-size: 1em; + } + &.is-5 { + font-size: 0.875em; + } + &.is-6 { + font-size: 0.85em; + } + } + + .paragraph { + margin-top: 0; + margin-bottom: 10px; + } + + img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + + &[align="right"] { + padding-left: 20px; + } + + &[align="left"] { + padding-right: 20px; + } } strong { @@ -44,24 +89,24 @@ ul { list-style-type: disc; list-style-position: outside; - margin-left: 16px; + margin-left: 1.143em; } ol { list-style-position: outside; - margin-left: 19px; + margin-left: 1.357em; } blockquote { - margin: 4px 10px 4px 10px; - border-radius: 3px; + margin: 0.286em 0.714em; + border-radius: 4px; background-color: var(--panel-bg-color); - padding: 2px 4px 2px 6px; + padding: 0.143em 0.286em 0.143em 0.429em; } pre.codeblock { background-color: var(--panel-bg-color); - margin: 4px 10px; + margin: 0.286em 0.714em; padding: 0.4em 0.7em; border-radius: 4px; position: relative; @@ -83,11 +128,11 @@ right: 0; border-radius: 4px; backdrop-filter: blur(8px); - margin: 2px 2px; - padding: 4px 4px; + margin: 0.143em; + padding: 0.286em; align-items: center; justify-content: flex-end; - gap: 4px; + gap: 0.286em; } &:hover .codeblock-actions { @@ -98,6 +143,7 @@ code { color: var(--main-text-color); font: var(--fixed-font); + font-size: var(--markdown-fixed-font-size); border-radius: 4px; } @@ -105,41 +151,13 @@ outline: 2px solid var(--accent-color); } - .heading { - font-weight: semibold; - padding-top: 6px; - } - - .heading.is-1 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 6px; - font-size: 2em; - } - .heading.is-2 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 6px; - font-size: 1.5em; - } - .heading.is-3 { - font-size: 1.25em; - } - .heading.is-4 { - font-size: 1em; - } - .heading.is-5 { - font-size: 0.875em; - } - .heading.is-6 { - font-size: 0.85em; - } - .waveblock { - margin: 16px 0; + margin: 1.143em 0; .wave-block-content { display: flex; align-items: center; - padding: 12px; + padding: 0.857em; background-color: var(--highlight-bg-color); border: 1px solid var(--border-color); border-radius: 8px; @@ -150,15 +168,15 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 2.857em; + height: 2.857em; background-color: black; border-radius: 8px; - margin-right: 12px; + margin-right: 0.857em; } .wave-block-icon i { - font-size: 18px; + font-size: 1.125em; color: var(--secondary-text-color); } @@ -168,19 +186,18 @@ } .wave-block-filename { - font-size: 14px; + font-size: 1em; font-weight: 500; color: var(--main-text-color); } .wave-block-size { - font-size: 12px; + font-size: 0.857em; color: var(--secondary-text-color); } } } - // The TOC view should scroll independently of the contents view. .toc { max-width: 40%; height: 100%; @@ -192,21 +209,20 @@ top: 0; display: flex; flex-direction: column; - gap: 5px; + gap: 0.357em; text-wrap: wrap; h4 { - padding-left: 5px; + padding-left: 0.357em; } .toc-item { cursor: pointer; --indent-factor: 1; - - // The 5px offset in the padding will ensure that when the text in the item wraps, it indents slightly. + // The offset in the padding will ensure that when the text in the item wraps, it indents slightly. // The indent factor is set in the React code and denotes the depth of the item in the TOC tree. - padding-left: calc((var(--indent-factor) - 1) * 10px + 5px); - text-indent: -5px; + padding-left: calc((var(--indent-factor) - 1) * 0.714em + 0.357em); + text-indent: -0.357em; } } } diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index c6e58fb63..5e6f596cf 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -3,11 +3,13 @@ import { CopyButton } from "@/app/element/copybutton"; import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin"; -import { MarkdownContentBlockType, transformBlocks } from "@/app/element/markdown-util"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getWebServerEndpoint } from "@/util/endpoints"; -import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; +import { + MarkdownContentBlockType, + resolveRemoteFile, + resolveSrcSet, + transformBlocks, +} from "@/app/element/markdown-util"; +import { useAtomValueSafe } from "@/util/util"; import { clsx } from "clsx"; import { Atom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; @@ -108,8 +110,34 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { ); }; -const MarkdownSource = (props: React.HTMLAttributes) => { - return null; +const MarkdownSource = ({ + props, + resolveOpts, +}: { + props: React.HTMLAttributes & { + srcSet?: string; + media?: string; + }; + resolveOpts: MarkdownResolveOpts; +}) => { + const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); + const [resolving, setResolving] = useState(true); + + useEffect(() => { + const resolvePath = async () => { + const resolved = await resolveSrcSet(props.srcSet, resolveOpts); + setResolvedSrcSet(resolved); + setResolving(false); + }; + + resolvePath(); + }, [props.srcSet]); + + if (resolving) { + return null; + } + + return ; }; interface WaveBlockProps { @@ -148,16 +176,11 @@ const MarkdownImg = ({ resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrc, setResolvedSrc] = useState(props.src); + const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); const [resolvedStr, setResolvedStr] = useState(null); const [resolving, setResolving] = useState(true); useEffect(() => { - if (props.src.startsWith("http://") || props.src.startsWith("https://")) { - setResolving(false); - setResolvedSrc(props.src); - setResolvedStr(null); - return; - } if (props.src.startsWith("data:image/")) { setResolving(false); setResolvedSrc(props.src); @@ -170,23 +193,20 @@ const MarkdownImg = ({ setResolvedStr(`[img:${props.src}]`); return; } + const resolveFn = async () => { - const route = makeConnRoute(resolveOpts.connName); - const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, props.src], { - route: route, - }); - const usp = new URLSearchParams(); - usp.set("path", fileInfo.path); - if (!isBlank(resolveOpts.connName)) { - usp.set("connection", resolveOpts.connName); - } - const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); - setResolvedSrc(streamingUrl); + const [resolvedSrc, resolvedSrcSet] = await Promise.all([ + resolveRemoteFile(props.src, resolveOpts), + resolveSrcSet(props.srcSet, resolveOpts), + ]); + + setResolvedSrc(resolvedSrc); + setResolvedSrcSet(resolvedSrcSet); setResolvedStr(null); setResolving(false); }; resolveFn(); - }, [props.src]); + }, [props.src, props.srcSet]); if (resolving) { return null; @@ -195,7 +215,7 @@ const MarkdownImg = ({ return {resolvedStr}; } if (resolvedSrc != null) { - return ; + return ; } return [img]; }; @@ -210,6 +230,8 @@ type MarkdownProps = { resolveOpts?: MarkdownResolveOpts; scrollable?: boolean; rehype?: boolean; + fontSizeOverride?: number; + fixedFontSizeOverride?: number; }; const Markdown = ({ @@ -219,6 +241,8 @@ const Markdown = ({ style, className, resolveOpts, + fontSizeOverride, + fixedFontSizeOverride, scrollable = true, rehype = true, onClickExecute, @@ -262,7 +286,9 @@ const Markdown = ({ h5: (props: React.HTMLAttributes) => , h6: (props: React.HTMLAttributes) => , img: (props: React.HTMLAttributes) => , - source: (props: React.HTMLAttributes) => , + source: (props: React.HTMLAttributes) => ( + + ), code: Code, pre: (props: React.HTMLAttributes) => ( @@ -301,12 +327,15 @@ const Markdown = ({ ...(defaultSchema.attributes?.span || []), // Allow all class names starting with `hljs-`. ["className", /^hljs-./], + ["srcset"], + ["media"], + ["type"], // Alternatively, to allow only certain class names: // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] ], waveblock: [["blockkey"]], }, - tagNames: [...(defaultSchema.tagNames || []), "span", "waveblock"], + tagNames: [...(defaultSchema.tagNames || []), "span", "waveblock", "picture", "source"], }), () => rehypeSlug({ prefix: idPrefix }), ]; @@ -349,8 +378,15 @@ const Markdown = ({ ); }; + const mergedStyle = { ...style }; + if (fontSizeOverride != null) { + mergedStyle["--markdown-font-size"] = `${fontSizeOverride}px`; + } + if (fixedFontSizeOverride != null) { + mergedStyle["--markdown-fixed-font-size"] = `${fixedFontSizeOverride}px`; + } return ( -
+
{scrollable ? : } {toc && ( diff --git a/frontend/app/theme.scss b/frontend/app/theme.scss index ad9931a4b..9f25430c4 100644 --- a/frontend/app/theme.scss +++ b/frontend/app/theme.scss @@ -1,8 +1,6 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -// Used for syntax highlighting in markdown - :root { --main-text-color: #f7f7f7; --title-font-size: 18px; @@ -16,8 +14,10 @@ --accent-color: rgb(88, 193, 66); --panel-bg-color: rgba(31, 33, 31, 0.5); --highlight-bg-color: rgba(255, 255, 255, 0.2); - --markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + --markdown-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + --markdown-font-size: 14px; + --markdown-fixed-font-size: 12px; --error-color: rgb(229, 77, 46); --warning-color: rgb(224, 185, 86); --success-color: rgb(78, 154, 6); diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 3a5c1e1bd..0c89d9428 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -783,6 +783,8 @@ function makePreviewModel(blockId: string, nodeModel: BlockNodeModel): PreviewMo function MarkdownPreview({ model }: SpecializedViewProps) { const connName = useAtomValue(model.connection); const fileInfo = useAtomValue(model.statFile); + const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize")); + const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize")); const resolveOpts: MarkdownResolveOpts = useMemo(() => { return { connName: connName, @@ -791,7 +793,13 @@ function MarkdownPreview({ model }: SpecializedViewProps) { }, [connName, fileInfo.dir]); return (
- +
); } diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss index c35a5e24e..4a2020e5c 100644 --- a/frontend/app/view/waveai/waveai.scss +++ b/frontend/app/view/waveai/waveai.scss @@ -131,9 +131,6 @@ outline: none; overflow: auto; overflow-wrap: anywhere; - font-family: var(--termfontfamily); - font-weight: normal; - line-height: var(--termlineheight); height: 21px; } } diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 6c0c02a38..b84bdd37f 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -569,7 +569,7 @@ const ChatWindow = memo( interface ChatInputProps { value: string; - termFontSize: number; + baseFontSize: number; onChange: (e: React.ChangeEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; onMouseDown: (e: React.MouseEvent) => void; @@ -577,7 +577,7 @@ interface ChatInputProps { } const ChatInput = forwardRef( - ({ value, onChange, onKeyDown, onMouseDown, termFontSize, model }, ref) => { + ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { const textAreaRef = useRef(null); useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); @@ -594,7 +594,7 @@ const ChatInput = forwardRef( // Adjust the height of the textarea to fit the text const textAreaMaxLines = 5; - const textAreaLineHeight = termFontSize * 1.5; + const textAreaLineHeight = baseFontSize * 1.5; const textAreaMinHeight = textAreaLineHeight; const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; @@ -608,7 +608,7 @@ const ChatInput = forwardRef( const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); textAreaRef.current.style.height = newHeight + "px"; }, - [termFontSize] + [baseFontSize] ); useEffect(() => { @@ -624,7 +624,7 @@ const ChatInput = forwardRef( onMouseDown={onMouseDown} // When the user clicks on the textarea onChange={onChange} onKeyDown={onKeyDown} - style={{ fontSize: termFontSize }} + style={{ fontSize: baseFontSize }} placeholder="Ask anything..." value={value} > @@ -642,7 +642,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { const [value, setValue] = useState(""); const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - const termFontSize: number = 14; + const baseFontSize: number = 14; const msgWidths = {}; const locked = useAtomValue(model.locked); @@ -815,7 +815,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { onChange={handleTextAreaChange} onKeyDown={handleTextAreaKeyDown} onMouseDown={handleTextAreaMouseDown} - termFontSize={termFontSize} + baseFontSize={baseFontSize} />