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:
Scott Bell 2023-10-03 00:28:02 +02:00 committed by GitHub
parent 2243381d52
commit 6947c912a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 960 additions and 740 deletions

View File

@ -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);
} }
/** /**

View File

@ -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();
});
}); });

View File

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

View File

@ -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?')

View File

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

View File

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

View File

@ -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));
} }

View File

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

View File

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

View File

@ -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()) {