relative markdown text (#1489)

This commit is contained in:
Mike Sawka 2024-12-16 16:04:07 -08:00 committed by GitHub
parent 51bd45bd2b
commit f1cd6b933d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 246 additions and 102 deletions

View File

@ -20,6 +20,9 @@
"[less]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},

View File

@ -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<string | null> => {
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<string> => {
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(", ");
};

View File

@ -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;
}
}
}

View File

@ -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<HTMLSourceElement>) => {
return null;
const MarkdownSource = ({
props,
resolveOpts,
}: {
props: React.HTMLAttributes<HTMLSourceElement> & {
srcSet?: string;
media?: string;
};
resolveOpts: MarkdownResolveOpts;
}) => {
const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet);
const [resolving, setResolving] = useState<boolean>(true);
useEffect(() => {
const resolvePath = async () => {
const resolved = await resolveSrcSet(props.srcSet, resolveOpts);
setResolvedSrcSet(resolved);
setResolving(false);
};
resolvePath();
}, [props.srcSet]);
if (resolving) {
return null;
}
return <source srcSet={resolvedSrcSet} media={props.media} />;
};
interface WaveBlockProps {
@ -148,16 +176,11 @@ const MarkdownImg = ({
resolveOpts: MarkdownResolveOpts;
}) => {
const [resolvedSrc, setResolvedSrc] = useState<string>(props.src);
const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet);
const [resolvedStr, setResolvedStr] = useState<string>(null);
const [resolving, setResolving] = useState<boolean>(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 <span>{resolvedStr}</span>;
}
if (resolvedSrc != null) {
return <img {...props} src={resolvedSrc} />;
return <img {...props} src={resolvedSrc} srcSet={resolvedSrcSet} />;
}
return <span>[img]</span>;
};
@ -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<HTMLHeadingElement>) => <Heading props={props} hnum={5} />,
h6: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={6} />,
img: (props: React.HTMLAttributes<HTMLImageElement>) => <MarkdownImg props={props} resolveOpts={resolveOpts} />,
source: (props: React.HTMLAttributes<HTMLSourceElement>) => <MarkdownSource {...props} />,
source: (props: React.HTMLAttributes<HTMLSourceElement>) => (
<MarkdownSource props={props} resolveOpts={resolveOpts} />
),
code: Code,
pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
<CodeBlock children={props.children} onClickExecute={onClickExecute} />
@ -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 (
<div className={clsx("markdown", className)} style={style}>
<div className={clsx("markdown", className)} style={mergedStyle}>
{scrollable ? <ScrollableMarkdown /> : <NonScrollableMarkdown />}
{toc && (
<OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}>

View File

@ -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);

View File

@ -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<MarkdownResolveOpts>(() => {
return {
connName: connName,
@ -791,7 +793,13 @@ function MarkdownPreview({ model }: SpecializedViewProps) {
}, [connName, fileInfo.dir]);
return (
<div className="view-preview view-preview-markdown">
<Markdown textAtom={model.fileContent} showTocAtom={model.markdownShowToc} resolveOpts={resolveOpts} />
<Markdown
textAtom={model.fileContent}
showTocAtom={model.markdownShowToc}
resolveOpts={resolveOpts}
fontSizeOverride={fontSizeOverride}
fixedFontSizeOverride={fixedFontSizeOverride}
/>
</div>
);
}

View File

@ -131,9 +131,6 @@
outline: none;
overflow: auto;
overflow-wrap: anywhere;
font-family: var(--termfontfamily);
font-weight: normal;
line-height: var(--termlineheight);
height: 21px;
}
}

View File

@ -569,7 +569,7 @@ const ChatWindow = memo(
interface ChatInputProps {
value: string;
termFontSize: number;
baseFontSize: number;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
@ -577,7 +577,7 @@ interface ChatInputProps {
}
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
({ value, onChange, onKeyDown, onMouseDown, termFontSize, model }, ref) => {
({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement);
@ -594,7 +594,7 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
// 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<HTMLTextAreaElement, ChatInputProps>(
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<HTMLTextAreaElement, ChatInputProps>(
onMouseDown={onMouseDown} // When the user clicks on the textarea
onChange={onChange}
onKeyDown={onKeyDown}
style={{ fontSize: termFontSize }}
style={{ fontSize: baseFontSize }}
placeholder="Ask anything..."
value={value}
></textarea>
@ -642,7 +642,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => {
const [value, setValue] = useState("");
const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(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}
/>
</div>
<Button className={buttonClass} onClick={handleButtonPress}>

View File

@ -490,6 +490,8 @@ declare global {
"term:vdomblockid"?: string;
"term:vdomtoolbarblockid"?: string;
"web:zoom"?: number;
"markdown:fontsize"?: number;
"markdown:fixedfontsize"?: number;
"vdom:*"?: boolean;
"vdom:initialized"?: boolean;
"vdom:correlationid"?: string;
@ -638,6 +640,8 @@ declare global {
"autoupdate:intervalms"?: number;
"autoupdate:installonquit"?: boolean;
"autoupdate:channel"?: string;
"markdown:fontsize"?: number;
"markdown:fixedfontsize"?: number;
"preview:showhiddenfiles"?: boolean;
"tab:preset"?: string;
"widget:*"?: boolean;

View File

@ -116,6 +116,7 @@
"overlayscrollbars": "^2.10.1",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "^5.4.1",
"parse-srcset": "^1.0.2",
"pngjs": "^7.0.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",

View File

@ -96,6 +96,9 @@ const (
MetaKey_WebZoom = "web:zoom"
MetaKey_MarkdownFontSize = "markdown:fontsize"
MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize"
MetaKey_VDomClear = "vdom:*"
MetaKey_VDomInitialized = "vdom:initialized"
MetaKey_VDomCorrelationId = "vdom:correlationid"

View File

@ -97,6 +97,9 @@ type MetaTSType struct {
WebZoom float64 `json:"web:zoom,omitempty"`
MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"`
MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"`
VDomClear bool `json:"vdom:*,omitempty"`
VDomInitialized bool `json:"vdom:initialized,omitempty"`
VDomCorrelationId string `json:"vdom:correlationid,omitempty"`

View File

@ -46,6 +46,9 @@ const (
ConfigKey_AutoUpdateInstallOnQuit = "autoupdate:installonquit"
ConfigKey_AutoUpdateChannel = "autoupdate:channel"
ConfigKey_MarkdownFontSize = "markdown:fontsize"
ConfigKey_MarkdownFixedFontSize = "markdown:fixedfontsize"
ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles"
ConfigKey_TabPreset = "tab:preset"

View File

@ -73,6 +73,9 @@ type SettingsType struct {
AutoUpdateInstallOnQuit bool `json:"autoupdate:installonquit,omitempty"`
AutoUpdateChannel string `json:"autoupdate:channel,omitempty"`
MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"`
MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"`
PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"`
TabPreset string `json:"tab:preset,omitempty"`

View File

@ -16290,6 +16290,13 @@ __metadata:
languageName: node
linkType: hard
"parse-srcset@npm:^1.0.2":
version: 1.0.2
resolution: "parse-srcset@npm:1.0.2"
checksum: 10c0/2f268e3d110d4c53d06ed2a8e8ee61a7da0cee13bf150819a6da066a8ca9b8d15b5600d6e6cae8be940e2edc50ee7c1e1052934d6ec858324065ecef848f0497
languageName: node
linkType: hard
"parse5-htmlparser2-tree-adapter@npm:^7.0.0":
version: 7.1.0
resolution: "parse5-htmlparser2-tree-adapter@npm:7.1.0"
@ -22003,6 +22010,7 @@ __metadata:
overlayscrollbars: "npm:^2.10.1"
overlayscrollbars-react: "npm:^0.5.6"
papaparse: "npm:^5.4.1"
parse-srcset: "npm:^1.0.2"
pngjs: "npm:^7.0.0"
prettier: "npm:^3.4.2"
prettier-plugin-jsdoc: "npm:^1.3.0"