(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

@@ -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,