// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as React from '@theia/core/shared/react';
import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
import { NotebookModel } from '../view-model/notebook-model';
import { CellRenderer, observeCellHeight } from './notebook-cell-list-view';
import { NotebookCellModel } from '../view-model/notebook-cell-model';
import { CellEditor } from './notebook-cell-editor';
import { inject, injectable } from '@theia/core/shared/inversify';
import { MonacoEditorServices } from '@theia/monaco/lib/browser/monaco-editor';
import { CommandRegistry, nls } from '@theia/core';
import { NotebookContextManager } from '../service/notebook-context-manager';
import { NotebookOptionsService } from '../service/notebook-options';
import { NotebookCodeCellStatus } from './notebook-code-cell-view';
import { NotebookEditorFindMatch, NotebookEditorFindMatchOptions } from './notebook-find-widget';
import * as mark from 'advanced-mark.js';
import { NotebookCellEditorService } from '../service/notebook-cell-editor-service';
import { NotebookCellStatusBarService } from '../service/notebook-cell-status-bar-service';
import { LabelParser } from '@theia/core/lib/browser/label-parser';
import { NotebookViewModel } from '../view-model/notebook-view-model';
@injectable()
export class NotebookMarkdownCellRenderer implements CellRenderer {
@inject(MarkdownRenderer)
private readonly markdownRenderer: MarkdownRenderer;
@inject(MonacoEditorServices)
protected readonly monacoServices: MonacoEditorServices;
@inject(NotebookContextManager)
protected readonly notebookContextManager: NotebookContextManager;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(NotebookOptionsService)
protected readonly notebookOptionsService: NotebookOptionsService;
@inject(NotebookCellEditorService)
protected readonly notebookCellEditorService: NotebookCellEditorService;
@inject(NotebookCellStatusBarService)
protected readonly notebookCellStatusBarService: NotebookCellStatusBarService;
@inject(LabelParser)
protected readonly labelParser: LabelParser;
@inject(NotebookViewModel)
protected readonly notebookViewModel: NotebookViewModel;
render(notebookModel: NotebookModel, cell: NotebookCellModel): React.ReactNode {
return ;
}
renderSidebar(notebookModel: NotebookModel, cell: NotebookCellModel): React.ReactNode {
return
;
}
renderDragImage(cell: NotebookCellModel): HTMLElement {
const dragImage = document.createElement('div');
dragImage.style.width = this.notebookContextManager.context?.clientWidth + 'px';
const markdownString = new MarkdownStringImpl(cell.source, { supportHtml: true, isTrusted: true });
const markdownElement = this.markdownRenderer.render(markdownString).element;
dragImage.appendChild(markdownElement);
return dragImage;
}
}
interface MarkdownCellProps {
markdownRenderer: MarkdownRenderer;
monacoServices: MonacoEditorServices;
commandRegistry: CommandRegistry;
cell: NotebookCellModel;
notebookModel: NotebookModel;
notebookViewModel: NotebookViewModel;
notebookContextManager: NotebookContextManager;
notebookOptionsService: NotebookOptionsService;
notebookCellEditorService: NotebookCellEditorService;
notebookCellStatusBarService: NotebookCellStatusBarService;
labelParser: LabelParser;
}
function MarkdownCell({
markdownRenderer, monacoServices, cell, notebookModel, notebookViewModel, notebookContextManager,
notebookOptionsService, commandRegistry, notebookCellEditorService, notebookCellStatusBarService,
labelParser
}: MarkdownCellProps): React.JSX.Element {
const [editMode, setEditMode] = React.useState(notebookViewModel.cellViewModels.get(cell.handle)?.editing || false);
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
let empty = false;
React.useEffect(() => {
if (!editMode) {
const listener = cell.onDidChangeContent(type => {
if (type === 'content') {
forceUpdate();
}
});
return () => listener.dispose();
}
}, [editMode, cell]);
React.useEffect(() => {
const cellViewModel = notebookViewModel.cellViewModels.get(cell.handle);
const listener = cellViewModel?.onDidRequestCellEditChange(cellEdit => setEditMode(cellEdit));
return () => listener?.dispose();
}, [editMode, notebookViewModel, cell]);
React.useEffect(() => {
if (!editMode) {
const instance = new mark(markdownContent);
cell.onMarkdownFind = options => {
instance.unmark();
if (empty) {
return [];
}
return searchInMarkdown(instance, options);
};
return () => {
cell.onMarkdownFind = undefined;
instance.unmark();
};
}
}, [editMode, cell.source]);
let markdownContent: HTMLElement[] = React.useMemo(() => {
const markdownString = new MarkdownStringImpl(cell.source, { supportHtml: true, isTrusted: true });
const rendered = markdownRenderer.render(markdownString).element;
const children: HTMLElement[] = [];
rendered.childNodes.forEach(child => {
if (child instanceof HTMLElement) {
children.push(child);
}
});
return children;
}, [cell.source]);
if (markdownContent.length === 0) {
const italic = document.createElement('i');
italic.className = 'theia-notebook-empty-markdown';
italic.innerText = nls.localizeByDefault('Empty markdown cell, double-click or press enter to edit.');
italic.style.pointerEvents = 'none';
markdownContent = [italic];
empty = true;
}
return editMode ?
( observeCellHeight(ref, cell)}>
notebookViewModel.cellViewModels.get(cell.handle)?.requestFocusEditor()} />
) :
( notebookViewModel.cellViewModels.get(cell.handle)?.requestEdit()}
ref={node => {
node?.replaceChildren(...markdownContent);
observeCellHeight(node, cell);
}}
/>);
}
function searchInMarkdown(instance: mark, options: NotebookEditorFindMatchOptions): NotebookEditorFindMatch[] {
const matches: NotebookEditorFindMatch[] = [];
const markOptions: mark.MarkOptions & mark.RegExpOptions = {
className: 'theia-find-match',
diacritics: false,
caseSensitive: options.matchCase,
acrossElements: true,
separateWordSearch: false,
each: node => {
matches.push(new MarkdownEditorFindMatch(node));
}
};
if (options.regex || options.wholeWord) {
let search = options.search;
if (options.wholeWord) {
if (!options.regex) {
search = escapeRegExp(search);
}
search = '\\b' + search + '\\b';
}
instance.markRegExp(new RegExp(search, options.matchCase ? '' : 'i'), markOptions);
} else {
instance.mark(options.search, markOptions);
}
return matches;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
class MarkdownEditorFindMatch implements NotebookEditorFindMatch {
constructor(readonly node: Node) { }
private _selected = false;
get selected(): boolean {
return this._selected;
}
set selected(selected: boolean) {
this._selected = selected;
const className = 'theia-find-match-selected';
if (this.node instanceof HTMLElement) {
if (selected) {
this.node.classList.add(className);
} else {
this.node.classList.remove(className);
}
}
}
show(): void {
if (this.node instanceof HTMLElement) {
this.node.scrollIntoView({
behavior: 'instant',
block: 'center'
});
}
}
}