mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
179
app/client/widgets/MarkdownTextBox.ts
Normal file
179
app/client/widgets/MarkdownTextBox.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
@@ -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');
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user