(core) Add Markdown cell format

Summary:
Text columns can now display their values as Markdown-formatted text
by changing their cell format to "Markdown". A minimal subset of the
Markdown specification is currently supported.

Test Plan: Browser tests.

Reviewers: Spoffy, dsagal

Reviewed By: Spoffy, dsagal

Subscribers: dsagal, Spoffy

Differential Revision: https://phab.getgrist.com/D4326
This commit is contained in:
George Gevoian
2024-08-22 23:51:09 -04:00
parent 5c486e686e
commit 292c894b93
26 changed files with 353 additions and 84 deletions

View File

@@ -135,7 +135,9 @@ class ParagraphRenderer extends FormRenderer {
return css.paragraph(
css.paragraph.cls(`-alignment-${this.layoutNode.alignment || 'left'}`),
el => {
el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor'));
el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor', {
async: false,
}));
},
);
}

View File

@@ -505,7 +505,7 @@ export const cssMarkdownRender = styled('div', `
export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {
return cssMarkdownRender(el => {
dom.autoDisposeElem(el, subscribeBindable(obs, val => {
el.innerHTML = sanitizeHTML(marked(val));
el.innerHTML = sanitizeHTML(marked(val, {async: false}));
}));
}, ...args);
}

View File

@@ -25,5 +25,5 @@ export function markdown(markdownObs: BindableValue<string>): DomElementMethod {
}
function setMarkdownValue(elem: Element, markdownValue: string): void {
elem.innerHTML = sanitizeHTML(marked(markdownValue));
elem.innerHTML = sanitizeHTML(marked(markdownValue, {async: false}));
}

View File

@@ -4,7 +4,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {sanitizeTutorialHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
@@ -13,7 +13,7 @@ import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal, modal} from 'app/client/ui2018/modals';
import {parseUrlId} from 'app/common/gristUrls';
import {dom, makeTestId, Observable, styled} from 'grainjs';
import {marked} from 'marked';
import {marked, Token} from 'marked';
import debounce = require('lodash/debounce');
import range = require('lodash/range');
import sortBy = require('lodash/sortBy');
@@ -219,7 +219,7 @@ export class DocTutorial extends FloatingPopup {
return value ? String(value) : undefined;
};
const walkTokens = (token: marked.Token) => {
const walkTokens = (token: Token) => {
if (token.type === 'image') {
imageUrls.push(token.href);
}
@@ -231,13 +231,13 @@ export class DocTutorial extends FloatingPopup {
let slideContent = getValue('slide_content');
if (!slideContent) { return null; }
slideContent = sanitizeHTML(await marked.parse(slideContent, {
slideContent = sanitizeTutorialHTML(await marked.parse(slideContent, {
async: true, renderer, walkTokens
}));
let boxContent = getValue('box_content');
if (boxContent) {
boxContent = sanitizeHTML(await marked.parse(boxContent, {
boxContent = sanitizeTutorialHTML(await marked.parse(boxContent, {
async: true, renderer, walkTokens
}));
}

View File

@@ -2,7 +2,7 @@ import {marked} from 'marked';
export const renderer = new marked.Renderer();
renderer.image = (href: string | null, title: string | null, _text: string) => {
renderer.image = ({href, title}) => {
let classes = 'doc-tutorial-popup-thumbnail';
const hash = href?.split('#')?.[1];
if (hash) {
@@ -17,6 +17,6 @@ renderer.image = (href: string | null, title: string | null, _text: string) => {
</div>`;
};
renderer.link = (href: string | null, _title: string | null, text: string) => {
renderer.link = ({href, text}) => {
return `<a href="${href}" target="_blank">${text}</a>`;
};

View File

@@ -0,0 +1,12 @@
import {gristIconLink} from 'app/client/ui2018/links';
import escape from 'lodash/escape';
import {marked} from 'marked';
export const renderer = new marked.Renderer();
renderer.link = ({href, text}) => gristIconLink(href, text).outerHTML;
// Disable Markdown features that we aren't ready to support yet.
renderer.hr = ({raw}) => raw;
renderer.html = ({raw}) => escape(raw);
renderer.image = ({raw}) => raw;

View File

@@ -1,16 +1,30 @@
import DOMPurify from 'dompurify';
import createDOMPurifier from 'dompurify';
const config = {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowFullscreen'],
};
export function sanitizeHTML(source: string | Node): string {
return defaultPurifier.sanitize(source);
}
DOMPurify.addHook('uponSanitizeAttribute', (node) => {
export function sanitizeTutorialHTML(source: string | Node): string {
return tutorialPurifier.sanitize(source, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowFullscreen'],
});
}
const defaultPurifier = createDOMPurifier();
defaultPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
const tutorialPurifier = createDOMPurifier();
tutorialPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
tutorialPurifier.addHook('uponSanitizeElement', handleSanitizeTutorialElement);
function handleSanitizeAttribute(node: Element) {
if (!('target' in node)) { return; }
node.setAttribute('target', '_blank');
});
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
}
function handleSanitizeTutorialElement(node: Element, data: createDOMPurifier.SanitizeElementHookEvent) {
if (data.tagName !== 'iframe') { return; }
const src = node.getAttribute('src');
@@ -18,9 +32,5 @@ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
return;
}
return node.parentNode?.removeChild(node);
});
export function sanitizeHTML(source: string | Node): string {
return DOMPurify.sanitize(source, config);
node.parentNode?.removeChild(node);
}

View File

@@ -23,6 +23,7 @@ export type IconName = "ChartArea" |
"FieldFunctionEqual" |
"FieldInteger" |
"FieldLink" |
"FieldMarkdown" |
"FieldNumeric" |
"FieldReference" |
"FieldSpinner" |
@@ -185,6 +186,7 @@ export const IconList: IconName[] = ["ChartArea",
"FieldFunctionEqual",
"FieldInteger",
"FieldLink",
"FieldMarkdown",
"FieldNumeric",
"FieldReference",
"FieldSpinner",

View File

@@ -895,6 +895,13 @@ export const theme = {
undefined, colors.slate),
widgetGallerySecondaryHeaderBgHover: new CustomProp(
'theme-widget-gallery-secondary-header-bg-hover', undefined, '#7E7E85'),
/* Markdown Cell */
markdownCellLightBg: new CustomProp('theme-markdown-cell-light-bg', undefined, colors.lightGrey),
markdownCellLightBorder: new CustomProp('theme-markdown-cell-light-border', undefined,
colors.mediumGreyOpaque),
markdownCellMediumBorder: new CustomProp('theme-markdown-cell-medium-border', undefined,
colors.darkGrey),
};
const cssColors = values(colors).map(v => v.decl()).join('\n');

View File

@@ -56,7 +56,7 @@ import { IconName } from './IconList';
/**
* Defaults for all icons.
*/
const iconDiv = styled('div', `
const iconStyles = `
position: relative;
display: inline-block;
vertical-align: middle;
@@ -66,24 +66,35 @@ const iconDiv = styled('div', `
width: 16px;
height: 16px;
background-color: var(--icon-color, var(--grist-theme-text, black));
`);
`;
export const cssIconBackground = styled(iconDiv, `
background-color: var(--icon-background, inherit);
-webkit-mask: none;
& .${iconDiv.className} {
transition: inherit;
display: block;
}
`);
const cssIconDiv = styled('div', iconStyles);
const cssIconSpan = styled('span', iconStyles);
export function icon(name: IconName, ...domArgs: DomElementArg[]): HTMLElement {
return iconDiv(
return cssIconDiv(
dom.style('-webkit-mask-image', `var(--icon-${name})`),
...domArgs
);
}
export function iconSpan(name: IconName, ...domArgs: DomElementArg[]): HTMLElement {
return cssIconSpan(
dom.style('-webkit-mask-image', `var(--icon-${name})`),
...domArgs
);
}
export const cssIconSpanBackground = styled(cssIconSpan, `
background-color: var(--icon-background, inherit);
-webkit-mask: none;
& .${cssIconSpan.className} {
transition: inherit;
display: block;
}
`);
/**
* Container box for an icon to serve as a button..
*/

View File

@@ -1,9 +1,9 @@
import {findLinks} from 'app/client/lib/textUtils';
import { sameDocumentUrlState, urlState } from 'app/client/models/gristUrlState';
import { hideInPrintView, testId, theme } from 'app/client/ui2018/cssVars';
import {cssIconBackground, icon} from 'app/client/ui2018/icons';
import { CellValue } from 'app/plugin/GristData';
import { dom, DomArg, IDomArgs, Observable, styled } from 'grainjs';
import {sameDocumentUrlState, urlState} from 'app/client/models/gristUrlState';
import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
import {cssIconSpanBackground, iconSpan} from 'app/client/ui2018/icons';
import {CellValue} from 'app/plugin/GristData';
import {dom, DomArg, IDomArgs, Observable, styled} from 'grainjs';
/**
* Styling for a simple <A HREF> link.
@@ -37,6 +37,19 @@ export function gristLink(href: string|Observable<string>, ...args: IDomArgs<HTM
);
}
export function gristIconLink(href: string, label = href) {
return cssMaybeWrap(
gristLink(href,
cssIconSpanBackground(
iconSpan("FieldLink", testId('tb-link-icon')),
dom.cls(cssHoverInText.className),
),
),
linkColor(label),
testId("text-link"),
);
}
/**
* If possible (i.e. if `url` points to somewhere in the current document)
* use pushUrl to navigate without reloading or opening a new tab
@@ -60,17 +73,7 @@ export function makeLinks(text: string) {
const domElements: DomArg[] = [];
for (const {value, isLink} of findLinks(text)) {
if (isLink) {
// Wrap link with a span to provide hover on and to override wrapping.
domElements.push(cssMaybeWrap(
gristLink(value,
cssIconBackground(
icon("FieldLink", testId('tb-link-icon')),
dom.cls(cssHoverInText.className),
),
),
linkColor(value),
testId("text-link")
));
domElements.push(gristIconLink(value));
} else {
domElements.push(value);
}

View File

@@ -31,7 +31,8 @@ import {Computed, Disposable, dom, DomElementArg, makeTestId, MutableObsArray,
obsArray, Observable, styled, subscribeElem} from 'grainjs';
import debounce from 'lodash/debounce';
import noop from 'lodash/noop';
import {marked} from 'marked';
import {Marked} from 'marked';
import {markedHighlight} from 'marked-highlight';
import {v4 as uuidv4} from 'uuid';
const t = makeT('FormulaEditor');
@@ -802,6 +803,7 @@ class ChatHistory extends Disposable {
public lastSuggestedFormula: Computed<string|null>;
private _element: HTMLElement;
private _marked: Marked;
constructor(private _options: {
column: ColumnRec,
@@ -845,6 +847,17 @@ class ChatHistory extends Disposable {
this.lastSuggestedFormula = Computed.create(this, use => {
return [...use(this.conversationHistory)].reverse().find(({formula}) => formula)?.formula ?? null;
});
const highlightCodePromise = buildCodeHighlighter({maxLines: 60});
this._marked = new Marked(
markedHighlight({
async: true,
highlight: async (code) => {
const highlightCode = await highlightCodePromise;
return highlightCode(code);
},
})
);
}
public thinking(on = true) {
@@ -864,10 +877,6 @@ class ChatHistory extends Disposable {
}
}
public supportsMarkdown() {
return this._options.column.chatHistory.peek().get().state !== undefined;
}
public addResponse(message: ChatMessage) {
// Clear any thinking from messages.
this.thinking(false);
@@ -958,6 +967,10 @@ class ChatHistory extends Disposable {
);
}
private _supportsMarkdown() {
return this._options.column.chatHistory.peek().get().state !== undefined;
}
private _buildIntroMessage() {
return cssAiIntroMessage(
cssAvatar(cssAiImage()),
@@ -1010,14 +1023,10 @@ class ChatHistory extends Disposable {
* Renders the message as markdown if possible, otherwise as a code block.
*/
private _render(message: string, ...args: DomElementArg[]) {
if (this.supportsMarkdown()) {
if (this._supportsMarkdown()) {
return dom('div',
(el) => subscribeElem(el, gristThemeObs(), async () => {
const highlightCode = await buildCodeHighlighter({maxLines: 60});
const content = sanitizeHTML(marked(message, {
highlight: (code) => highlightCode(code)
}));
el.innerHTML = content;
el.innerHTML = sanitizeHTML(await this._marked.parse(message));
}),
...args
);

View File

@@ -2,7 +2,7 @@ import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { constructUrl } from 'app/client/models/gristUrlState';
import { testId, theme } from 'app/client/ui2018/cssVars';
import { cssIconBackground, icon } from 'app/client/ui2018/icons';
import { cssIconSpanBackground, iconSpan } from 'app/client/ui2018/icons';
import { cssHoverIn, gristLink } from 'app/client/ui2018/links';
import { NTextBox } from 'app/client/widgets/NTextBox';
import { CellValue } from 'app/common/DocActions';
@@ -27,8 +27,8 @@ export class HyperLinkTextBox extends NTextBox {
dom.cls('text_wrapping', this.wrapping),
dom.maybe((use) => Boolean(use(value)), () =>
gristLink(url,
cssIconBackground(
icon("FieldLink", testId('tb-link-icon')),
cssIconSpanBackground(
iconSpan("FieldLink", testId('tb-link-icon')),
dom.cls(cssHoverOnField.className),
),
testId('tb-link'),

View File

@@ -0,0 +1,179 @@
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { buildCodeHighlighter } from 'app/client/ui/CodeHighlight';
import { renderer } from 'app/client/ui/MarkdownCellRenderer';
import { sanitizeHTML } from 'app/client/ui/sanitizeHTML';
import { theme, vars } from 'app/client/ui2018/cssVars';
import { gristThemeObs } from 'app/client/ui2018/theme';
import { NTextBox } from 'app/client/widgets/NTextBox';
import { dom, styled, subscribeBindable } from 'grainjs';
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
/**
* Creates a widget for displaying Markdown-formatted text.
*/
export class MarkdownTextBox extends NTextBox {
private _marked: Marked;
constructor(field: ViewFieldRec) {
super(field);
const highlightCodePromise = buildCodeHighlighter({maxLines: 60});
this._marked = new Marked(
markedHighlight({
async: true,
highlight: async (code) => {
const highlightCode = await highlightCodePromise;
return highlightCode(code);
},
})
);
}
public buildDom(row: DataRowModel) {
const value = row.cells[this.field.colId()];
return cssFieldClip(
cssFieldClip.cls('-text-wrap', this.wrapping),
dom.style('text-align', this.alignment),
(el) => dom.autoDisposeElem(el, subscribeBindable(value, async () => {
el.innerHTML = sanitizeHTML(await this._marked.parse(String(value.peek()), {gfm: false, renderer}));
this.field.viewSection().events.trigger('rowHeightChange');
})),
// Note: the DOM needs to be rebuilt on theme change, as Ace needs to switch between
// light and dark themes. If we switch to using a custom Grist Ace theme (with CSS
// variables used for highlighting), we can remove the listener below (and elsewhere).
(el) => dom.autoDisposeElem(el, subscribeBindable(gristThemeObs(), async () => {
el.innerHTML = sanitizeHTML(await this._marked.parse(String(value.peek()), {gfm: false, renderer}));
})),
);
}
}
const cssFieldClip = styled('div.field_clip', `
white-space: nowrap;
&-text-wrap {
white-space: normal;
word-break: break-word;
}
&:not(&-text-wrap) p,
&:not(&-text-wrap) h1,
&:not(&-text-wrap) h2,
&:not(&-text-wrap) h3,
&:not(&-text-wrap) h4,
&:not(&-text-wrap) h5,
&:not(&-text-wrap) h6
{
overflow: hidden;
text-overflow: ellipsis;
}
& > *:first-child {
margin-top: 0px !important;
}
& > *:last-child {
margin-bottom: 0px !important;
}
& h1, & h2, & h3, & h4, & h5, & h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
& h1 {
padding-bottom: .3em;
font-size: 2em;
}
& h2 {
padding-bottom: .3em;
font-size: 1.5em;
}
& h3 {
font-size: 1.25em;
}
& h4 {
font-size: 1em;
}
& h5 {
font-size: .875em;
}
& h6 {
color: ${theme.lightText};
font-size: .85em;
}
& p, & blockquote, & ul, & ol, & dl, & pre {
margin-top: 0px;
margin-bottom: 10px;
}
& code, & pre {
color: ${theme.text};
font-size: 85%;
background-color: ${theme.markdownCellLightBg};
border: 0;
border-radius: 6px;
}
& code {
padding: .2em .4em;
margin: 0;
white-space: nowrap;
}
&-text-wrap code {
white-space: break-spaces;
}
& pre {
padding: 16px;
overflow: auto;
line-height: 1.45;
}
& pre code {
font-size: 100%;
display: inline;
max-width: auto;
margin: 0;
padding: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background: transparent;
}
& pre > code {
background: transparent;
white-space: nowrap;
word-break: normal;
margin: 0;
padding: 0;
}
&-text-wrap pre > code {
white-space: normal;
}
& pre .ace-chrome, & pre .ace-dracula {
background: ${theme.markdownCellLightBg} !important;
}
& .ace_indent-guide {
background: none;
}
& .ace_static_highlight {
white-space: nowrap;
}
& ul, & ol {
list-style-position: inside;
padding-left: 1em;
}
& li > ol, & li > ul {
margin: 0;
}
&:not(&-text-wrap) li {
overflow: hidden;
text-overflow: ellipsis;
}
& li + li,
& li > ol > li:first-child,
& li > ul > li:first-child {
margin-top: .25em;
}
& blockquote {
font-size: ${vars.mediumFontSize};
border-left: .25em solid ${theme.markdownCellMediumBorder};
padding: 0 1em;
}
`);

View File

@@ -28,9 +28,7 @@ export class NTextBox extends NewAbstractWidget {
this.alignment = fromKo(this.options.prop('alignment'));
this.wrapping = fromKo(this.field.wrap);
this.autoDispose(this.wrapping.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange');
}));
this._addRowHeightListeners();
}
public buildConfigDom(_gristDoc: GristDoc): DomContents {
@@ -112,4 +110,12 @@ export class NTextBox extends NewAbstractWidget {
makeLinks(use(this.valueFormatter).formatAny(use(value), t)))
);
}
private _addRowHeightListeners() {
for (const obs of [this.wrapping, fromKo(this.field.config.widget)]) {
this.autoDispose(obs.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange');
}));
}
}
}

View File

@@ -66,6 +66,15 @@ export const typeDefs: any = {
wrap: undefined,
}
},
Markdown: {
cons: 'MarkdownTextBox',
editCons: 'TextEditor',
icon: 'FieldMarkdown',
options: {
alignment: 'left',
wrap: undefined,
}
},
HyperLink: {
cons: 'HyperLinkTextBox',
editCons: 'HyperLinkEditor',

View File

@@ -11,6 +11,7 @@ import DateTimeEditor from 'app/client/widgets/DateTimeEditor';
import DateTimeTextBox from 'app/client/widgets/DateTimeTextBox';
import {HyperLinkEditor} from 'app/client/widgets/HyperLinkEditor';
import {HyperLinkTextBox} from 'app/client/widgets/HyperLinkTextBox';
import {MarkdownTextBox} from 'app/client/widgets/MarkdownTextBox';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {NTextBox} from 'app/client/widgets/NTextBox';
@@ -36,6 +37,7 @@ export const nameToWidget = {
'NumericEditor': NumericEditor,
'HyperLinkTextBox': HyperLinkTextBox,
'HyperLinkEditor': HyperLinkEditor,
'MarkdownTextBox': MarkdownTextBox,
'Spinner': Spinner,
'CheckBox': ToggleCheckBox,
'CheckBoxEditor': CheckBoxEditor,

View File

@@ -447,6 +447,9 @@ export const ThemeColors = t.iface([], {
"widget-gallery-secondary-header-fg": "string",
"widget-gallery-secondary-header-bg": "string",
"widget-gallery-secondary-header-bg-hover": "string",
"markdown-cell-light-bg": "string",
"markdown-cell-light-border": "string",
"markdown-cell-medium-border": "string",
});
const exportedTypeSuite: t.ITypeSuite = {

View File

@@ -583,6 +583,11 @@ export interface ThemeColors {
'widget-gallery-secondary-header-fg': string;
'widget-gallery-secondary-header-bg': string;
'widget-gallery-secondary-header-bg-hover': string;
/* Markdown Cell */
'markdown-cell-light-bg': string;
'markdown-cell-light-border': string;
'markdown-cell-medium-border': string;
}
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;

View File

@@ -562,4 +562,9 @@ export const GristDark: ThemeColors = {
'widget-gallery-secondary-header-fg': '#FFFFFF',
'widget-gallery-secondary-header-bg': '#70707D',
'widget-gallery-secondary-header-bg-hover': '#60606D',
/* Markdown Cell */
'markdown-cell-light-bg': '#494958',
'markdown-cell-light-border': '#32323F',
'markdown-cell-medium-border': '#555563',
};

View File

@@ -562,4 +562,9 @@ export const GristLight: ThemeColors = {
'widget-gallery-secondary-header-fg': '#FFFFFF',
'widget-gallery-secondary-header-bg': '#929299',
'widget-gallery-secondary-header-bg-hover': '#7E7E85',
/* Markdown Cell */
'markdown-cell-light-bg': '#F7F7F7',
'markdown-cell-light-border': '#E8E8E8',
'markdown-cell-medium-border': '#D9D9D9',
};