Skip to content

Commit

Permalink
Render markdown for preferences
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew committed Nov 19, 2021
1 parent 7889beb commit ff923f3
Show file tree
Hide file tree
Showing 14 changed files with 189 additions and 30 deletions.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"keytar": "7.2.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"markdown-it": "^8.4.0",
"nsfw": "^2.1.2",
"p-debounce": "^2.1.0",
"perfect-scrollbar": "^1.3.0",
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/browser/core-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ export const corePreferenceSchema: PreferenceSchema = {
'keyCode',
],
default: 'code',
description: nls.localizeByDefault(
'Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`.')
markdownDescription: nls.localizeByDefault('Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`.')
},
'window.menuBarVisibility': {
type: 'string',
Expand Down Expand Up @@ -89,7 +88,7 @@ export const corePreferenceSchema: PreferenceSchema = {
'workbench.editor.highlightModifiedTabs': {
'type': 'boolean',
// eslint-disable-next-line max-len
'description': nls.localizeByDefault('Controls whether a top border is drawn on modified (dirty) editor tabs or not. This value is ignored when `#workbench.editor.showTabs#` is disabled.'),
'markdownDescription': nls.localizeByDefault('Controls whether a top border is drawn on modified (dirty) editor tabs or not. This value is ignored when `#workbench.editor.showTabs#` is disabled.'),
'default': false
},
'workbench.editor.closeOnFileDelete': {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ export * from './core-preferences';
export * from './view-container';
export * from './breadcrumbs';
export * from './tooltip-service';
export * from './markdown-renderer';
83 changes: 83 additions & 0 deletions packages/core/src/browser/markdown-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/********************************************************************************
* Copyright (C) 2021 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 WITH Classpath-exception-2.0
********************************************************************************/

import * as DOMPurify from 'dompurify';
import * as markdownit from 'markdown-it';

export class MarkdownRenderer {

protected engine: markdownit;
protected domParser = new DOMParser();
protected callbacks = new Map<string, ((element: Element) => Element | void)[]>();

constructor(engine?: markdownit) {
this.engine = engine ?? markdownit();
}

/**
* Adds a modification callback that is applied to every element with the specified tag after rendering to HTML.
*
* @param tag The tag that this modification applies to
* @param callback The modification to apply on every selected rendered element. Can either modify the element in place or return a new element.
*/
modify<K extends keyof HTMLElementTagNameMap>(tag: K, callback: (element: HTMLElementTagNameMap[K]) => Element | void): MarkdownRenderer {
if (this.callbacks.has(tag)) {
this.callbacks.get(tag)!.push(callback);
} else {
this.callbacks.set(tag, [callback]);
}
return this;
}

render(markdown: string): HTMLElement {
return this.renderInternal(this.engine.render(markdown));
}

renderInline(markdown: string): HTMLElement {
return this.renderInternal(this.engine.renderInline(markdown));
}

protected renderInternal(renderedHtml: string): HTMLElement {
const div = this.sanitizeHtml(renderedHtml);
for (const [tag, calls] of this.callbacks) {
// As the `taggedElements` collection may change while calling `replaceChild`,
// we have to create a copy of the collection beforehand.
const taggedElements = div.getElementsByTagName(tag);
const elements: Element[] = [];
for (let i = 0; i < taggedElements.length; i++) {
const element = taggedElements.item(i);
if (element) {
elements.push(element);
}
}
for (const callback of calls) {
for (const element of elements) {
const result = callback(element);
if (result) {
div.replaceChild(result, element);
}
}
}
}
return div;
}

protected sanitizeHtml(html: string): HTMLElement {
const div = document.createElement('div');
div.innerHTML = DOMPurify.sanitize(html);
return div;
}
}
12 changes: 6 additions & 6 deletions packages/editor/src/browser/editor-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const codeEditorPreferenceProperties = {
},
'editor.semanticHighlighting.enabled': {
'enum': [true, false, 'configuredByTheme'],
'enumDescriptions': [
'markdownEnumDescriptions': [
nls.localizeByDefault('Semantic highlighting enabled for all color themes.'),
nls.localizeByDefault('Semantic highlighting disabled for all color themes.'),
nls.localizeByDefault('Semantic highlighting is configured by the current color theme\'s `semanticHighlighting` setting.')
Expand Down Expand Up @@ -327,7 +327,7 @@ const codeEditorPreferenceProperties = {
'default': 0,
'minimum': 0,
'maximum': 100,
'description': nls.localizeByDefault('Controls the font size in pixels for CodeLens. When set to `0`, the 90% of `#editor.fontSize#` is used.')
'markdownDescription': nls.localizeByDefault('Controls the font size in pixels for CodeLens. When set to `0`, the 90% of `#editor.fontSize#` is used.')
},
'editor.colorDecorators': {
'description': nls.localizeByDefault('Controls whether the editor should render the inline color decorators and color picker.'),
Expand Down Expand Up @@ -392,11 +392,11 @@ const codeEditorPreferenceProperties = {
'maximum': 1073741824
},
'editor.cursorSurroundingLinesStyle': {
'enumDescriptions': [
'markdownEnumDescriptions': [
nls.localizeByDefault('`cursorSurroundingLines` is enforced only when triggered via the keyboard or API.'),
nls.localizeByDefault('`cursorSurroundingLines` is enforced always.')
],
'description': nls.localizeByDefault('Controls when `cursorSurroundingLines` should be enforced.'),
'markdownDescription': nls.localizeByDefault('Controls when `cursorSurroundingLines` should be enforced.'),
'type': 'string',
'enum': [
'default',
Expand Down Expand Up @@ -1111,7 +1111,7 @@ const codeEditorPreferenceProperties = {
'editor.inlineHints.fontSize': {
'type': 'number',
'default': EDITOR_FONT_DEFAULTS.fontSize,
description: nls.localizeByDefault('Controls font size of inline hints in the editor. When set to `0`, the 90% of `#editor.fontSize#` is used.')
markdownDescription: nls.localizeByDefault('Controls font size of inline hints in the editor. When set to `0`, the 90% of `#editor.fontSize#` is used.')
},
'editor.inlineHints.fontFamily': {
'type': 'string',
Expand Down Expand Up @@ -1173,7 +1173,7 @@ const codeEditorPreferenceProperties = {
'editor.suggest.insertHighlight': {
'type': 'boolean',
'default': false,
'description': nls.localize('theia/editor/suggest.insertHighlight', 'Controls whether unexpected text modifications while accepting completions should be highlighted, e.g `insertMode` is `replace` but the completion only supports `insert`.')
'markdownDescription': nls.localize('theia/editor/suggest.insertHighlight', 'Controls whether unexpected text modifications while accepting completions should be highlighted, e.g `insertMode` is `replace` but the completion only supports `insert`.')
},
'editor.suggest.filterGraceful': {
'type': 'boolean',
Expand Down
4 changes: 2 additions & 2 deletions packages/filesystem/src/browser/filesystem-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const filesystemPreferenceSchema: PreferenceSchema = {
type: 'object',
default: { '**/.git': true, '**/.svn': true, '**/.hg': true, '**/CVS': true, '**/.DS_Store': true },
// eslint-disable-next-line max-len
description: nls.localize('theia/filesystem/filesExclude', 'Configure glob patterns for excluding files and folders. For example, the file Explorer decides which files and folders to show or hide based on this setting. Refer to the `#search.exclude#` setting to define search specific excludes.'),
markdownDescription: nls.localize('theia/filesystem/filesExclude', 'Configure glob patterns for excluding files and folders. For example, the file Explorer decides which files and folders to show or hide based on this setting. Refer to the `#search.exclude#` setting to define search specific excludes.'),
scope: 'resource'
},
'files.enableTrash': {
Expand All @@ -65,7 +65,7 @@ export const filesystemPreferenceSchema: PreferenceSchema = {
},
'files.associations': {
type: 'object',
description: nls.localizeByDefault(
markdownDescription: nls.localizeByDefault(
'Configure file associations to languages (e.g. `\"*.extension\": \"html\"`). These have precedence over the default associations of the languages installed.'
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const HostedPluginConfigSchema: PreferenceSchema = {
items: {
type: 'string'
},
description: nls.localize(
markdownDescription: nls.localize(
'theia/plugin-dev/launchOutFiles',
'Array of glob patterns for locating generated JavaScript files (`${pluginPath}` will be replaced by plugin actual path).'
),
Expand Down
11 changes: 11 additions & 0 deletions packages/preferences/src/browser/preference-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@ export class PreferenceTreeModel extends TreeModelImpl {
}
}

getNodeFromPreferenceId(id: string): Preference.LeafNode | undefined {
if (this.root) {
for (const node of new TopDownTreeIterator(this.root)) {
if (Preference.LeafNode.is(node) && node.preferenceId === id) {
return node;
}
}
}
return undefined;
}

/**
* @returns true if selection changed, false otherwise
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('preference-tree-label-provider', () => {
visible: true,
selected: false,
depth: 2,
preferenceId: property,
preference: { data: {} }
};

Expand Down
1 change: 1 addition & 0 deletions packages/preferences/src/browser/util/preference-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export namespace Preference {
export interface LeafNode extends BaseTreeNode {
depth: number;
preference: { data: PreferenceDataProperty };
preferenceId: string;
}

export namespace LeafNode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
********************************************************************************/

import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { PreferenceService, ContextMenuRenderer, PreferenceInspection, PreferenceScope, PreferenceProvider } from '@theia/core/lib/browser';
import {
PreferenceService, ContextMenuRenderer, PreferenceInspection,
PreferenceScope, PreferenceProvider, MarkdownRenderer, codicon, animationFrame
} from '@theia/core/lib/browser';
import { Preference, PreferenceMenus } from '../../util/preference-types';
import { PreferenceTreeLabelProvider } from '../../util/preference-tree-label-provider';
import { PreferencesScopeTabBar } from '../preference-scope-tabbar-widget';
Expand Down Expand Up @@ -157,11 +160,13 @@ export abstract class PreferenceLeafNodeRenderer<ValueType extends JSONValue, In
protected interactable: InteractableType;
protected inspection: PreferenceInspection<ValueType> | undefined;
protected isModifiedFromDefault = false;
protected markdownRenderer: MarkdownRenderer;

@postConstruct()
protected init(): void {
this.setId();
this.updateInspection();
this.markdownRenderer = this.buildMarkdownRenderer();
this.domNode = this.createDomNode();
this.updateModificationStatus();
}
Expand All @@ -170,6 +175,61 @@ export abstract class PreferenceLeafNodeRenderer<ValueType extends JSONValue, In
this.inspection = this.preferenceService.inspect<ValueType>(this.id, this.scopeTracker.currentScope.uri);
}

protected buildMarkdownRenderer(): MarkdownRenderer {
return new MarkdownRenderer()
.modify('a', link => {
link.addEventListener('click', e => this.openLink(e, link.href));
})
.modify('code', code => {
const innerText = code.innerText;
// Linked preferences always start and end with `#`
if (innerText.startsWith('#') && innerText.endsWith('#')) {
const preferenceId = innerText.substring(1, innerText.length - 1);
const preferenceNode = this.model.getNodeFromPreferenceId(preferenceId);
if (preferenceNode) {
let name = this.labelProvider.getName(preferenceNode);
const prefix = this.labelProvider.getPrefix(preferenceNode);
if (prefix) {
name = prefix + name;
}
const link = document.createElement('a');
link.addEventListener('click', e => this.selectPreference(e, preferenceId));
link.innerText = name;
link.href = `#${preferenceId}`;
return link;
}
}
return code;
});
}

protected openLink(event: MouseEvent, href: string): void {
event.preventDefault();
event.stopPropagation();
// Exclude right click
if (event.which < 3) {
window.open(href, '_blank', 'noopener');
}
}

protected async selectPreference(event: MouseEvent, preferenceId: string): Promise<void> {
event.preventDefault();
event.stopPropagation();
// Exclude right click
if (event.which < 3) {
const selector = `li[data-pref-id="${preferenceId}"]`;
const element = document.querySelector(selector);
if (element) {
if (element.clientHeight === 0 || element.clientWidth === 0) {
// We clear the search term as we have clicked on a hidden preference
await this.searchbar.updateSearchTerm('');
await animationFrame();
}
element.scrollIntoView();
}
}
}

protected createDomNode(): HTMLLIElement {
const wrapper = document.createElement('li');
wrapper.classList.add('single-pref');
Expand All @@ -191,7 +251,7 @@ export abstract class PreferenceLeafNodeRenderer<ValueType extends JSONValue, In
wrapper.appendChild(gutter);

const cog = document.createElement('i');
cog.className = 'codicon codicon-settings-gear settings-context-menu-btn';
cog.className = `${codicon('settings-gear', true)} settings-context-menu-btn`;
cog.setAttribute('aria-label', 'Open Context Menu');
cog.setAttribute('role', 'button');
cog.onclick = this.handleCogAction.bind(this);
Expand All @@ -205,11 +265,15 @@ export abstract class PreferenceLeafNodeRenderer<ValueType extends JSONValue, In
wrapper.appendChild(contentWrapper);

const { description, markdownDescription } = this.preferenceNode.preference.data;
const descriptionToUse = markdownDescription || description;
if (descriptionToUse) {
if (markdownDescription || description) {
const descriptionWrapper = document.createElement('div');
descriptionWrapper.classList.add('pref-description');
descriptionWrapper.textContent = descriptionToUse;
if (markdownDescription) {
const renderedDescription = this.markdownRenderer.renderInline(markdownDescription);
descriptionWrapper.appendChild(renderedDescription);
} else if (description) {
descriptionWrapper.textContent = description;
}
contentWrapper.appendChild(descriptionWrapper);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { codicon, ReactWidget, StatefulWidget } from '@theia/core/lib/browser';
import { injectable, postConstruct } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import debounce = require('@theia/core/shared/lodash.debounce');
import debounce = require('p-debounce');
import { Disposable, Emitter } from '@theia/core';
import { nls } from '@theia/core/lib/common/nls';

Expand Down Expand Up @@ -45,11 +45,9 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
this.update();
}

protected handleSearch = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.search(e.target.value);
};
protected handleSearch = (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => this.search(e.target.value);

protected search = debounce((value: string) => {
protected search = debounce(async (value: string) => {
this.onFilterStringChangedEmitter.fire(value);
this.update();
}, 200);
Expand All @@ -64,11 +62,11 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
* Clears the search input and all search results.
* @param e on-click mouse event.
*/
protected clearSearchResults = (e: React.MouseEvent): void => {
protected clearSearchResults = async (e: React.MouseEvent): Promise<void> => {
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
if (search) {
search.value = '';
this.search(search.value);
await this.search(search.value);
this.update();
}
};
Expand Down Expand Up @@ -127,13 +125,13 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
return search?.value;
}

updateSearchTerm(searchTerm: string): void {
async updateSearchTerm(searchTerm: string): Promise<void> {
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
if (!search) {
if (!search || search.value === searchTerm) {
return;
}
search.value = searchTerm;
this.search(search.value);
await this.search(search.value);
this.update();
}

Expand Down
Loading

0 comments on commit ff923f3

Please sign in to comment.