mirror of
https://github.com/nasa/openmct.git
synced 2025-02-21 09:52:04 +00:00
Add markdown to notebook entries (#7084)
* try marked out * fix url validation * now rendering blockquotes properly * add abbrv, link titles, and strikethrough * fix tests and lint * Closes #6060 - CSS resets and styling for markdown-related HTML markup in Notebook entries. - Better styling and cursor affordances for Notebook entry selection and editing interaction flow. * add line breaks option * Closes #6060 - Tab * Closes #6060 - Conversion of contenteditable-div to textarea started. - Stubbed in textarea with styles. * have it markdown with a textarea and adjust size automatically * Closes #6060 - Padding added back to text `div` area. * Closes #6060 - Styles added to support Shift Log and hover behavior for entries on locked pages. - Removed `--major` styling from Shift Log Commit Entries button to reduce confusion with entry commit button. - CSS code cleanups. * two step focus/edit. also scroll into view for editing * add markdown, strip all tags, and truncate * lint * remove unneeded code * fix notebook entry, selected page may also be null * fix existing notebook tests * lint * fix whitelist * readd whitelist * lint * fix link tests * fix tests * fix tagging test * add some markdown test * get rid of pause * add another sanitization step --------- Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com> Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
This commit is contained in:
parent
2243381d52
commit
6947c912a7
@ -34,7 +34,8 @@ async function enterTextEntry(page, text) {
|
|||||||
await page.locator(NOTEBOOK_DROP_AREA).click();
|
await page.locator(NOTEBOOK_DROP_AREA).click();
|
||||||
|
|
||||||
// enter text
|
// enter text
|
||||||
await page.locator('[aria-label="Notebook Entry"].is-selected div.c-ne__text').fill(text);
|
await page.getByLabel('Notebook Entry Display').last().click();
|
||||||
|
await page.getByLabel('Notebook Entry Input').last().fill(text);
|
||||||
await commitEntry(page);
|
await commitEntry(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +53,6 @@ async function dragAndDropEmbed(page, notebookObject) {
|
|||||||
await page.click('button[title="Show selected item in tree"]');
|
await page.click('button[title="Show selected item in tree"]');
|
||||||
// Drag and drop the SWG into the notebook
|
// Drag and drop the SWG into the notebook
|
||||||
await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA);
|
await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA);
|
||||||
await commitEntry(page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -279,8 +279,8 @@ test.describe('Notebook entry tests', () => {
|
|||||||
|
|
||||||
// Click .c-notebook__drag-area
|
// Click .c-notebook__drag-area
|
||||||
await page.locator('.c-notebook__drag-area').click();
|
await page.locator('.c-notebook__drag-area').click();
|
||||||
await expect(page.locator('[aria-label="Notebook Entry Input"]')).toBeVisible();
|
await expect(page.getByLabel('Notebook Entry Display')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="Notebook Entry"]')).toHaveClass(/is-selected/);
|
await expect(page.getByLabel('Notebook Entry', { exact: true })).toHaveClass(/is-selected/);
|
||||||
});
|
});
|
||||||
test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({
|
test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({
|
||||||
page
|
page
|
||||||
@ -369,6 +369,8 @@ test.describe('Notebook entry tests', () => {
|
|||||||
|
|
||||||
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||||
|
|
||||||
|
expect(await validLink.count()).toBe(1);
|
||||||
|
|
||||||
// Start waiting for popup before clicking. Note no await.
|
// Start waiting for popup before clicking. Note no await.
|
||||||
const popupPromise = page.waitForEvent('popup');
|
const popupPromise = page.waitForEvent('popup');
|
||||||
|
|
||||||
@ -378,8 +380,6 @@ test.describe('Notebook entry tests', () => {
|
|||||||
// Wait for the popup to load.
|
// Wait for the popup to load.
|
||||||
await popup.waitForLoadState();
|
await popup.waitForLoadState();
|
||||||
expect.soft(popup.url()).toContain('www.google.com');
|
expect.soft(popup.url()).toContain('www.google.com');
|
||||||
|
|
||||||
expect(await validLink.count()).toBe(1);
|
|
||||||
});
|
});
|
||||||
test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({
|
test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({
|
||||||
page
|
page
|
||||||
@ -447,6 +447,8 @@ test.describe('Notebook entry tests', () => {
|
|||||||
|
|
||||||
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||||
|
|
||||||
|
expect(await validLink.count()).toBe(1);
|
||||||
|
|
||||||
// Start waiting for popup before clicking. Note no await.
|
// Start waiting for popup before clicking. Note no await.
|
||||||
const popupPromise = page.waitForEvent('popup');
|
const popupPromise = page.waitForEvent('popup');
|
||||||
|
|
||||||
@ -456,8 +458,6 @@ test.describe('Notebook entry tests', () => {
|
|||||||
// Wait for the popup to load.
|
// Wait for the popup to load.
|
||||||
await popup.waitForLoadState();
|
await popup.waitForLoadState();
|
||||||
expect.soft(popup.url()).toContain('www.google.com');
|
expect.soft(popup.url()).toContain('www.google.com');
|
||||||
|
|
||||||
expect(await validLink.count()).toBe(1);
|
|
||||||
});
|
});
|
||||||
test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({
|
test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({
|
||||||
page
|
page
|
||||||
@ -482,4 +482,42 @@ test.describe('Notebook entry tests', () => {
|
|||||||
expect.soft(await sanitizedLink.count()).toBe(1);
|
expect.soft(await sanitizedLink.count()).toBe(1);
|
||||||
expect(await unsanitizedLink.count()).toBe(0);
|
expect(await unsanitizedLink.count()).toBe(0);
|
||||||
});
|
});
|
||||||
|
test('Can add markdown to a notebook entry', async ({ page }) => {
|
||||||
|
await page.goto(notebookObject.url);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
const headerMarkdown = `# Big Header\n## Large Header\n### Medium Header\n#### Small Header`;
|
||||||
|
await nbUtils.enterTextEntry(page, headerMarkdown);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Big Header' })).toBeVisible();
|
||||||
|
|
||||||
|
// Text markup
|
||||||
|
const markupText =
|
||||||
|
'**This is bold.** _This is italic_. `This is code`. ~This is strikethrough~';
|
||||||
|
await nbUtils.enterTextEntry(page, markupText);
|
||||||
|
await expect(page.locator('strong:has-text("This is bold.")')).toBeVisible();
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
const tablesText = '|Col 1|Col 2|Col3|\n|-|-|-|\n |Value 1|Value 2|Value 3|\n';
|
||||||
|
await nbUtils.enterTextEntry(page, tablesText);
|
||||||
|
await expect(page.getByRole('cell', { name: 'Value 2' })).toBeVisible();
|
||||||
|
|
||||||
|
// Links
|
||||||
|
const linksText =
|
||||||
|
'Raw links https://www.google.com and Markdown links like [Google](https://www.google.com) work';
|
||||||
|
await nbUtils.enterTextEntry(page, linksText);
|
||||||
|
await expect(page.getByRole('link', { name: 'https://www.google.com' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Google', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
const listsText = '- List item 1\n - Item 1A \n- List Item 2\n 1. Order 1\n 1. Order 2\n';
|
||||||
|
await nbUtils.enterTextEntry(page, listsText);
|
||||||
|
const childItem = page.locator('li:has-text("List Item 2") ol li:has-text("Order 2")');
|
||||||
|
await expect(childItem).toBeVisible();
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
const blockTest = '```javascript\nconst foo = "bar";\nconst bar = "foo";\n```';
|
||||||
|
await nbUtils.enterTextEntry(page, blockTest);
|
||||||
|
const codeBlock = page.locator('code.language-javascript:has-text("const foo = \\"bar\\";")');
|
||||||
|
await expect(codeBlock).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -192,8 +192,6 @@ test.describe('Snapshot image tests', () => {
|
|||||||
// be sure that entry was created
|
// be sure that entry was created
|
||||||
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
|
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
|
||||||
|
|
||||||
// click on image (need to click twice to focus)
|
|
||||||
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
|
|
||||||
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
|
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
|
||||||
|
|
||||||
// expect large image to be displayed
|
// expect large image to be displayed
|
||||||
|
@ -147,16 +147,14 @@ test.describe('Tagging in Notebooks @addInit', () => {
|
|||||||
type: 'issue',
|
type: 'issue',
|
||||||
description: 'https://github.com/nasa/openmct/issues/5823'
|
description: 'https://github.com/nasa/openmct/issues/5823'
|
||||||
});
|
});
|
||||||
|
|
||||||
await createNotebookEntryAndTags(page);
|
await createNotebookEntryAndTags(page);
|
||||||
|
|
||||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||||
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = 1`;
|
await page.getByLabel('Notebook Entry Display').last().click();
|
||||||
await page.locator(entryLocator).click();
|
await page.getByLabel('Notebook Entry Input').fill(`An entry without tags`);
|
||||||
await page.locator(entryLocator).fill(`An entry without tags`);
|
await page.locator('.c-ne__save-button > button').click();
|
||||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter');
|
|
||||||
|
|
||||||
await page.hover('[aria-label="Notebook Entry Input"] >> nth=1');
|
await page.hover('[aria-label="Notebook Entry Display"] >> nth=1');
|
||||||
await page.locator('button[title="Delete this entry"]').last().click();
|
await page.locator('button[title="Delete this entry"]').last().click();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('text=This action will permanently delete this entry. Do you wish to continue?')
|
page.locator('text=This action will permanently delete this entry. Do you wish to continue?')
|
||||||
|
@ -131,8 +131,9 @@ test.describe('Performance tests', () => {
|
|||||||
await page.evaluate(() => window.performance.mark('new-notebook-entry-created'));
|
await page.evaluate(() => window.performance.mark('new-notebook-entry-created'));
|
||||||
|
|
||||||
// Enter Notebook Entry text
|
// Enter Notebook Entry text
|
||||||
await page.locator('div.c-ne__text').last().fill('New Entry');
|
await page.getByLabel('Notebook Entry').last().click();
|
||||||
await page.keyboard.press('Enter');
|
await page.getByLabel('Notebook Entry Input').last().fill('New Entry');
|
||||||
|
await page.locator('.c-ne__save-button').click();
|
||||||
await page.evaluate(() => window.performance.mark('new-notebook-entry-filled'));
|
await page.evaluate(() => window.performance.mark('new-notebook-entry-filled'));
|
||||||
|
|
||||||
//Individual Notebook Entry Search
|
//Individual Notebook Entry Search
|
||||||
|
@ -52,6 +52,7 @@
|
|||||||
"karma-webpack": "5.0.0",
|
"karma-webpack": "5.0.0",
|
||||||
"location-bar": "3.0.1",
|
"location-bar": "3.0.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"marked": "9.0.3",
|
||||||
"mini-css-extract-plugin": "2.7.6",
|
"mini-css-extract-plugin": "2.7.6",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"moment-duration-format": "2.3.2",
|
"moment-duration-format": "2.3.2",
|
||||||
|
@ -19,7 +19,6 @@
|
|||||||
this source code distribution or the Licensing information page available
|
this source code distribution or the Licensing information page available
|
||||||
at runtime from the About dialog for additional information.
|
at runtime from the About dialog for additional information.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="c-notebook" :class="[{ 'c-notebook--restricted': isRestricted }]">
|
<div class="c-notebook" :class="[{ 'c-notebook--restricted': isRestricted }]">
|
||||||
<div class="c-notebook__head">
|
<div class="c-notebook__head">
|
||||||
@ -62,7 +61,13 @@
|
|||||||
@selectSection="selectSection"
|
@selectSection="selectSection"
|
||||||
@toggleNav="toggleNav"
|
@toggleNav="toggleNav"
|
||||||
/>
|
/>
|
||||||
<div class="c-notebook__page-view">
|
<div
|
||||||
|
class="c-notebook__page-view"
|
||||||
|
:class="{
|
||||||
|
'c-notebook--page-locked': selectedPage?.isLocked,
|
||||||
|
'c-notebook--page-unlocked': !selectedPage?.isLocked
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div class="c-notebook__page-view__header">
|
<div class="c-notebook__page-view__header">
|
||||||
<button
|
<button
|
||||||
class="c-notebook__toggle-nav-button c-icon-button c-icon-button--major icon-menu-hamburger"
|
class="c-notebook__toggle-nav-button c-icon-button c-icon-button--major icon-menu-hamburger"
|
||||||
@ -107,9 +112,9 @@
|
|||||||
class="c-telemetry-table__progress-bar"
|
class="c-telemetry-table__progress-bar"
|
||||||
:model="{ progressPerc: null }"
|
:model="{ progressPerc: null }"
|
||||||
/>
|
/>
|
||||||
<div v-if="selectedPage && selectedPage.isLocked" class="c-notebook__page-locked">
|
<div v-if="selectedPage && selectedPage.isLocked" class="c-notebook__page-locked-message">
|
||||||
<div class="icon-lock"></div>
|
<div class="icon-lock"></div>
|
||||||
<div class="c-notebook__page-locked__message">
|
<div class="c-notebook__page-locked-message-text">
|
||||||
This page has been committed and cannot be modified or removed
|
This page has been committed and cannot be modified or removed
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -142,7 +147,7 @@
|
|||||||
class="c-notebook__commit-entries-control"
|
class="c-notebook__commit-entries-control"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="c-button c-button--major commit-button icon-lock"
|
class="c-button commit-button icon-lock"
|
||||||
title="Commit entries and lock this page from further changes"
|
title="Commit entries and lock this page from further changes"
|
||||||
@click="lockPage()"
|
@click="lockPage()"
|
||||||
>
|
>
|
||||||
@ -185,6 +190,7 @@ import {
|
|||||||
import NotebookEntry from './NotebookEntry.vue';
|
import NotebookEntry from './NotebookEntry.vue';
|
||||||
import SearchResults from './SearchResults.vue';
|
import SearchResults from './SearchResults.vue';
|
||||||
import Sidebar from './Sidebar.vue';
|
import Sidebar from './Sidebar.vue';
|
||||||
|
|
||||||
function objectCopy(obj) {
|
function objectCopy(obj) {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
return JSON.parse(JSON.stringify(obj));
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="entry"
|
||||||
class="c-notebook__entry c-ne has-local-controls"
|
class="c-notebook__entry c-ne has-local-controls"
|
||||||
aria-label="Notebook Entry"
|
aria-label="Notebook Entry"
|
||||||
:class="{ locked: isLocked, 'is-selected': isSelectedEntry, 'is-editing': editMode }"
|
:class="{ locked: isLocked, 'is-selected': isSelectedEntry, 'is-editing': editMode }"
|
||||||
@ -56,7 +57,7 @@
|
|||||||
<template v-if="readOnly && result">
|
<template v-if="readOnly && result">
|
||||||
<div :id="entry.id" class="c-ne__text highlight" tabindex="0">
|
<div :id="entry.id" class="c-ne__text highlight" tabindex="0">
|
||||||
<TextHighlight
|
<TextHighlight
|
||||||
:text="formatValidUrls(entry.text)"
|
:text="convertMarkDownToHtml(entry.text)"
|
||||||
:highlight="highlightText"
|
:highlight="highlightText"
|
||||||
:highlight-class="'search-highlight'"
|
:highlight-class="'search-highlight'"
|
||||||
/>
|
/>
|
||||||
@ -64,18 +65,26 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="!isLocked">
|
<template v-else-if="!isLocked">
|
||||||
<div
|
<div
|
||||||
|
v-if="!editMode"
|
||||||
v-bind.prop="formattedText"
|
v-bind.prop="formattedText"
|
||||||
:id="entry.id"
|
:id="entry.id"
|
||||||
class="c-ne__text c-ne__input"
|
tabindex="-1"
|
||||||
|
aria-label="Notebook Entry Display"
|
||||||
|
class="c-ne__text"
|
||||||
|
@mouseover="checkEditability($event)"
|
||||||
|
@click="editingEntry($event)"
|
||||||
|
></div>
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:id="entry.id"
|
||||||
|
ref="entryInput"
|
||||||
|
v-model="entry.text"
|
||||||
|
class="c-ne__input"
|
||||||
aria-label="Notebook Entry Input"
|
aria-label="Notebook Entry Input"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:contenteditable="canEdit"
|
|
||||||
@mouseover="checkEditability($event)"
|
|
||||||
@mouseleave="canEdit = true"
|
@mouseleave="canEdit = true"
|
||||||
@mousedown="preventFocusIfNotSelected($event)"
|
|
||||||
@focus="editingEntry()"
|
|
||||||
@blur="updateEntryValue($event)"
|
@blur="updateEntryValue($event)"
|
||||||
></div>
|
></textarea>
|
||||||
<div v-if="editMode" class="c-ne__save-button">
|
<div v-if="editMode" class="c-ne__save-button">
|
||||||
<button class="c-button c-button--major icon-check"></button>
|
<button class="c-button c-button--major icon-check"></button>
|
||||||
</div>
|
</div>
|
||||||
@ -139,6 +148,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { Marked } from 'marked';
|
||||||
import Moment from 'moment';
|
import Moment from 'moment';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
@ -151,11 +161,49 @@ import {
|
|||||||
import NotebookEmbed from './NotebookEmbed.vue';
|
import NotebookEmbed from './NotebookEmbed.vue';
|
||||||
|
|
||||||
const SANITIZATION_SCHEMA = {
|
const SANITIZATION_SCHEMA = {
|
||||||
allowedTags: [],
|
allowedTags: [
|
||||||
allowedAttributes: {}
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'p',
|
||||||
|
'a',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
's',
|
||||||
|
'strike',
|
||||||
|
'code',
|
||||||
|
'hr',
|
||||||
|
'br',
|
||||||
|
'div',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'caption',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'pre',
|
||||||
|
'del',
|
||||||
|
'ins',
|
||||||
|
'mark',
|
||||||
|
'abbr'
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'target', 'class', 'title'],
|
||||||
|
code: ['class'],
|
||||||
|
abbr: ['title']
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const URL_REGEX =
|
|
||||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
|
||||||
const UNKNOWN_USER = 'Unknown';
|
const UNKNOWN_USER = 'Unknown';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -236,16 +284,15 @@ export default {
|
|||||||
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
||||||
},
|
},
|
||||||
formattedText() {
|
formattedText() {
|
||||||
// remove ANY tags
|
const text = this.entry.text;
|
||||||
const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
|
|
||||||
|
|
||||||
if (this.editMode || this.urlWhitelist.length === 0) {
|
if (this.editMode) {
|
||||||
return { innerText: text };
|
return { innerText: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = this.formatValidUrls(text);
|
const markDownHtml = this.convertMarkDownToHtml(text);
|
||||||
|
|
||||||
return { innerHTML: html };
|
return { innerHTML: markDownHtml };
|
||||||
},
|
},
|
||||||
isSelectedEntry() {
|
isSelectedEntry() {
|
||||||
return this.selectedEntryId === this.entry.id;
|
return this.selectedEntryId === this.entry.id;
|
||||||
@ -276,7 +323,23 @@ export default {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
editMode() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
// waiting for textarea to be rendered
|
||||||
|
this.$refs.entryInput?.focus();
|
||||||
|
this.adjustTextareaHeight();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
this.marked = new Marked();
|
||||||
|
this.renderer = new this.marked.Renderer();
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
const originalLinkRenderer = this.renderer.link;
|
||||||
|
this.renderer.link = this.validateLink.bind(this, originalLinkRenderer);
|
||||||
|
|
||||||
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
|
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
|
||||||
|
|
||||||
if (this.$refs.embedsWrapper) {
|
if (this.$refs.embedsWrapper) {
|
||||||
@ -309,6 +372,41 @@ export default {
|
|||||||
|
|
||||||
this.manageEmbedLayout();
|
this.manageEmbedLayout();
|
||||||
},
|
},
|
||||||
|
convertMarkDownToHtml(text) {
|
||||||
|
let markDownHtml = this.marked.parse(text, {
|
||||||
|
breaks: true,
|
||||||
|
renderer: this.renderer
|
||||||
|
});
|
||||||
|
markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA);
|
||||||
|
return markDownHtml;
|
||||||
|
},
|
||||||
|
adjustTextareaHeight() {
|
||||||
|
if (this.$refs.entryInput) {
|
||||||
|
this.$refs.entryInput.style.height = 'auto';
|
||||||
|
this.$refs.entryInput.style.height = `${this.$refs?.entryInput.scrollHeight}px`;
|
||||||
|
this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateLink(originalLinkRenderer, href, title, text) {
|
||||||
|
try {
|
||||||
|
const domain = new URL(href).hostname;
|
||||||
|
const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => {
|
||||||
|
return domain.endsWith(partialDomain);
|
||||||
|
});
|
||||||
|
if (!urlIsWhitelisted) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const linkHtml = originalLinkRenderer.call(this.renderer, href, title, text);
|
||||||
|
const linkHtmlWithTarget = linkHtml.replace(
|
||||||
|
/^<a /,
|
||||||
|
'<a class="c-hyperlink" target="_blank"'
|
||||||
|
);
|
||||||
|
return linkHtmlWithTarget;
|
||||||
|
} catch (error) {
|
||||||
|
// had error parsing this URL, just return the text
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
},
|
||||||
cancelEditMode(event) {
|
cancelEditMode(event) {
|
||||||
const isEditing = this.openmct.editor.isEditing();
|
const isEditing = this.openmct.editor.isEditing();
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@ -333,22 +431,6 @@ export default {
|
|||||||
deleteEntry() {
|
deleteEntry() {
|
||||||
this.$emit('deleteEntry', this.entry.id);
|
this.$emit('deleteEntry', this.entry.id);
|
||||||
},
|
},
|
||||||
formatValidUrls(text) {
|
|
||||||
return text.replace(URL_REGEX, (match) => {
|
|
||||||
const url = new URL(match);
|
|
||||||
const domain = url.hostname;
|
|
||||||
let result = match;
|
|
||||||
let isMatch = this.urlWhitelist.find((partialDomain) => {
|
|
||||||
return domain.endsWith(partialDomain);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMatch) {
|
|
||||||
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
manageEmbedLayout() {
|
manageEmbedLayout() {
|
||||||
if (this.$refs.embeds) {
|
if (this.$refs.embeds) {
|
||||||
const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;
|
const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;
|
||||||
@ -417,9 +499,6 @@ export default {
|
|||||||
|
|
||||||
return position;
|
return position;
|
||||||
},
|
},
|
||||||
forceBlur(event) {
|
|
||||||
event.target.blur();
|
|
||||||
},
|
|
||||||
formatTime(unixTime, timeFormat) {
|
formatTime(unixTime, timeFormat) {
|
||||||
return Moment.utc(unixTime).format(timeFormat);
|
return Moment.utc(unixTime).format(timeFormat);
|
||||||
},
|
},
|
||||||
@ -474,29 +553,26 @@ export default {
|
|||||||
|
|
||||||
this.$emit('updateEntry', this.entry);
|
this.$emit('updateEntry', this.entry);
|
||||||
},
|
},
|
||||||
preventFocusIfNotSelected($event) {
|
editingEntry(event) {
|
||||||
if (!this.isSelectedEntry) {
|
this.selectAndEmitEntry(event, this.entry);
|
||||||
$event.preventDefault();
|
if (this.isSelectedEntry) {
|
||||||
// blur the previous focused entry if clicking on non selected entry input
|
// selected and click, so we're ready to edit
|
||||||
const focusedElementId = document.activeElement?.id;
|
this.selectAndEmitEntry(event, this.entry);
|
||||||
if (focusedElementId !== this.entry.id) {
|
this.editMode = true;
|
||||||
document.activeElement.blur();
|
this.adjustTextareaHeight();
|
||||||
}
|
this.$emit('editingEntry');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editingEntry() {
|
|
||||||
this.editMode = true;
|
|
||||||
this.$emit('editingEntry');
|
|
||||||
},
|
|
||||||
updateEntryValue($event) {
|
updateEntryValue($event) {
|
||||||
this.editMode = false;
|
this.editMode = false;
|
||||||
const value = $event.target.innerText;
|
const rawEntryValue = $event.target.value;
|
||||||
this.entry.text = sanitizeHtml(value, SANITIZATION_SCHEMA);
|
const sanitizeInput = sanitizeHtml(rawEntryValue, { allowedAttributes: [], allowedTags: [] });
|
||||||
|
this.entry.text = sanitizeInput;
|
||||||
this.timestampAndUpdate();
|
this.timestampAndUpdate();
|
||||||
},
|
},
|
||||||
selectAndEmitEntry(event, entry) {
|
selectAndEmitEntry(event, entry) {
|
||||||
selectEntry({
|
selectEntry({
|
||||||
element: event.currentTarget,
|
element: this.$refs.entry,
|
||||||
entryId: entry.id,
|
entryId: entry.id,
|
||||||
domainObject: this.domainObject,
|
domainObject: this.domainObject,
|
||||||
openmct: this.openmct,
|
openmct: this.openmct,
|
||||||
|
@ -220,7 +220,7 @@ describe('Notebook plugin:', () => {
|
|||||||
const notebookEntryElements = element.querySelectorAll('.c-notebook__entry');
|
const notebookEntryElements = element.querySelectorAll('.c-notebook__entry');
|
||||||
const firstEntryText = getEntryText(0);
|
const firstEntryText = getEntryText(0);
|
||||||
expect(notebookEntryElements.length).toBe(2);
|
expect(notebookEntryElements.length).toBe(2);
|
||||||
expect(firstEntryText.innerText).toBe('First Test Entry');
|
expect(firstEntryText.innerText.trim()).toBe('First Test Entry');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('synchronization', () => {
|
describe('synchronization', () => {
|
||||||
@ -232,13 +232,13 @@ describe('Notebook plugin:', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates an entry when another user modifies it', () => {
|
it('updates an entry when another user modifies it', () => {
|
||||||
expect(getEntryText(0).innerText).toBe('First Test Entry');
|
expect(getEntryText(0).innerText.trim()).toBe('First Test Entry');
|
||||||
objectCloneToSyncFrom.configuration.entries['test-section-1']['test-page-1'][0].text =
|
objectCloneToSyncFrom.configuration.entries['test-section-1']['test-page-1'][0].text =
|
||||||
'Modified entry text';
|
'Modified entry text';
|
||||||
objectProviderObserver(objectCloneToSyncFrom);
|
objectProviderObserver(objectCloneToSyncFrom);
|
||||||
|
|
||||||
return Vue.nextTick().then(() => {
|
return Vue.nextTick().then(() => {
|
||||||
expect(getEntryText(0).innerText).toBe('Modified entry text');
|
expect(getEntryText(0).innerText.trim()).toBe('Modified entry text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -53,6 +53,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
import { identifierToString } from '../../../../src/tools/url';
|
import { identifierToString } from '../../../../src/tools/url';
|
||||||
import ObjectPath from '../../components/ObjectPath.vue';
|
import ObjectPath from '../../components/ObjectPath.vue';
|
||||||
import PreviewAction from '../../preview/PreviewAction';
|
import PreviewAction from '../../preview/PreviewAction';
|
||||||
@ -81,30 +84,13 @@ export default {
|
|||||||
},
|
},
|
||||||
getResultName() {
|
getResultName() {
|
||||||
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK) {
|
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK) {
|
||||||
const targetID = Object.keys(this.result.targets)[0];
|
const previewText = this.getNotebookPreviewText(this.result);
|
||||||
const entryIdToFind = this.result.targets[targetID].entryId;
|
return previewText;
|
||||||
const notebookModel = this.result.targetModels[0].configuration.entries;
|
|
||||||
|
|
||||||
const sections = Object.values(notebookModel);
|
|
||||||
for (const section of sections) {
|
|
||||||
const pages = Object.values(section);
|
|
||||||
for (const entries of pages) {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.id === entryIdToFind) {
|
|
||||||
return entry.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Could not find any matching Notebook entries';
|
|
||||||
} else if (
|
} else if (
|
||||||
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
|
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
|
||||||
) {
|
) {
|
||||||
const targetID = Object.keys(this.result.targets)[0];
|
const previewText = this.getGeospatialPreviewText(this.result);
|
||||||
const { layerName, name } = this.result.targets[targetID];
|
return previewText;
|
||||||
|
|
||||||
return layerName ? `${layerName} - ${name}` : name;
|
|
||||||
} else {
|
} else {
|
||||||
return this.result.targetModels[0].name;
|
return this.result.targetModels[0].name;
|
||||||
}
|
}
|
||||||
@ -119,6 +105,9 @@ export default {
|
|||||||
return this.result.fullTagModels[0].foregroundColor;
|
return this.result.fullTagModels[0].foregroundColor;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
beforeMount() {
|
||||||
|
this.marked = new Marked();
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.previewAction = new PreviewAction(this.openmct);
|
this.previewAction = new PreviewAction(this.openmct);
|
||||||
this.previewAction.on('isVisible', this.togglePreviewState);
|
this.previewAction.on('isVisible', this.togglePreviewState);
|
||||||
@ -129,6 +118,48 @@ export default {
|
|||||||
this.openmct.selection.off('change', this.fireAnnotationSelection);
|
this.openmct.selection.off('change', this.fireAnnotationSelection);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getNotebookEntryTextById(entryIdToFind, notebookModel) {
|
||||||
|
const sections = Object.values(notebookModel);
|
||||||
|
for (const section of sections) {
|
||||||
|
const pages = Object.values(section);
|
||||||
|
for (const entries of pages) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.id === entryIdToFind) {
|
||||||
|
return entry.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getNotebookPreviewText(result) {
|
||||||
|
const targetID = Object.keys(this.result.targets)[0];
|
||||||
|
const entryIdToFind = this.result.targets[targetID].entryId;
|
||||||
|
const notebookModel = this.result.targetModels[0].configuration.entries;
|
||||||
|
|
||||||
|
const entryText = this.getNotebookEntryTextById(entryIdToFind, notebookModel);
|
||||||
|
if (entryText === null) {
|
||||||
|
return 'Could not find any matching Notebook entries';
|
||||||
|
}
|
||||||
|
const markDownHtml = this.marked.parse(entryText, {
|
||||||
|
breaks: true
|
||||||
|
});
|
||||||
|
// strip everything
|
||||||
|
const cleanedHtml = sanitizeHtml(markDownHtml, { allowedAttributes: [], allowedTags: [] });
|
||||||
|
// strip to 64 characters
|
||||||
|
let truncatedText = cleanedHtml.substring(0, 64);
|
||||||
|
// add ellipsis if necessary
|
||||||
|
if (truncatedText.length < entryText.length) {
|
||||||
|
truncatedText = `${truncatedText}...`;
|
||||||
|
}
|
||||||
|
return truncatedText;
|
||||||
|
},
|
||||||
|
getGeospatialPreviewText(result) {
|
||||||
|
const targetID = Object.keys(this.result.targets)[0];
|
||||||
|
const { layerName, name } = this.result.targets[targetID];
|
||||||
|
|
||||||
|
return layerName ? `${layerName} - ${name}` : name;
|
||||||
|
},
|
||||||
clickedResult(event) {
|
clickedResult(event) {
|
||||||
const objectPath = this.domainObject.originalPath;
|
const objectPath = this.domainObject.originalPath;
|
||||||
if (this.openmct.editor.isEditing()) {
|
if (this.openmct.editor.isEditing()) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user