506 lines
15 KiB
JavaScript

import NoteContextAwareWidget from "../note_context_aware_widget.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import server from "../../services/server.js";
import contextMenuService from "../../services/context_menu.js";
import attributesParser from "../../services/attribute_parser.js";
import libraryLoader from "../../services/library_loader.js";
import froca from "../../services/froca.js";
import attributeRenderer from "../../services/attribute_renderer.js";
import noteCreateService from "../../services/note_create.js";
import treeService from "../../services/tree.js";
import attributeService from "../../services/attributes.js";
const HELP_TEXT = `
<p>To add label, just type e.g. <code>#rock</code> or if you want to add also value then e.g. <code>#year = 2020</code></p>
<p>For relation, type <code>~author = @</code> which should bring up an autocomplete where you can look up the desired note.</p>
<p>Alternatively you can add label and relation using the <code>+</code> button on the right side.</p>`;
const TPL = `
<div style="position: relative; padding-top: 10px; padding-bottom: 10px">
<style>
.attribute-list-editor {
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
padding: 0 0 0 5px !important;
margin: 0 !important;
max-height: 100px;
overflow: auto;
transition: opacity .1s linear;
}
.attribute-list-editor.ck-content .mention {
color: var(--muted-text-color) !important;
background: transparent !important;
}
.save-attributes-button {
color: var(--muted-text-color);
position: absolute;
bottom: 14px;
right: 25px;
cursor: pointer;
border: 1px solid transparent;
font-size: 130%;
}
.add-new-attribute-button {
color: var(--muted-text-color);
position: absolute;
bottom: 13px;
right: 0;
cursor: pointer;
border: 1px solid transparent;
font-size: 130%;
}
.add-new-attribute-button:hover, .save-attributes-button:hover {
border: 1px solid var(--main-border-color);
border-radius: 2px;
}
.attribute-errors {
color: red;
padding: 5px 50px 0px 5px; /* large right padding to avoid buttons */
}
</style>
<div class="attribute-list-editor" tabindex="200"></div>
<div class="bx bx-save save-attributes-button" title="Save attributes <enter>"></div>
<div class="bx bx-plus add-new-attribute-button" title="Add a new attribute"></div>
<div class="attribute-errors" style="display: none;"></div>
</div>
`;
const mentionSetup = {
feeds: [
{
marker: '@',
feed: queryText => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: item => {
const itemElement = document.createElement('button');
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
return itemElement;
},
minimumCharacters: 0
},
{
marker: '#',
feed: async queryText => {
const names = await server.get(`attributes/names/?type=label&query=${encodeURIComponent(queryText)}`);
return names.map(name => {
return {
id: '#' + name,
name: name
}
});
},
minimumCharacters: 0,
attributeMention: true
},
{
marker: '~',
feed: async queryText => {
const names = await server.get(`attributes/names/?type=relation&query=${encodeURIComponent(queryText)}`);
return names.map(name => {
return {
id: '~' + name,
name: name
}
});
},
minimumCharacters: 0,
attributeMention: true
}
]
};
const editorConfig = {
removePlugins: [
'Heading',
'Link',
'Autoformat',
'Bold',
'Italic',
'Underline',
'Strikethrough',
'Code',
'Superscript',
'Subscript',
'BlockQuote',
'Image',
'ImageCaption',
'ImageStyle',
'ImageToolbar',
'ImageUpload',
'ImageResize',
'List',
'TodoList',
'PasteFromOffice',
'Table',
'TableToolbar',
'TableProperties',
'TableCellProperties',
'Indent',
'IndentBlock',
'BlockToolbar',
'ParagraphButtonUI',
'HeadingButtonsUI',
'UploadimagePlugin',
'InternalLinkPlugin',
'MarkdownImportPlugin',
'CuttonotePlugin',
'TextTransformation',
'Font',
'FontColor',
'FontBackgroundColor',
'CodeBlock',
'SelectAll',
'IncludeNote',
'CutToNote',
'Mathematics',
'AutoformatMath',
'indentBlockShortcutPlugin',
'removeFormatLinksPlugin'
],
toolbar: {
items: []
},
placeholder: "Type the labels and relations here",
mention: mentionSetup
};
export default class AttributeEditorWidget extends NoteContextAwareWidget {
constructor(attributeDetailWidget) {
super();
this.attributeDetailWidget = attributeDetailWidget;
}
doRender() {
this.$widget = $(TPL);
this.$editor = this.$widget.find('.attribute-list-editor');
this.initialized = this.initEditor();
this.$editor.on('keydown', async e => {
if (e.which === 13) {
// allow autocomplete to fill the result textarea
setTimeout(() => this.save(), 100);
}
this.attributeDetailWidget.hide();
});
this.$editor.on('blur', () => this.save());
this.$addNewAttributeButton = this.$widget.find('.add-new-attribute-button');
this.$addNewAttributeButton.on('click', e => this.addNewAttribute(e));
this.$saveAttributesButton = this.$widget.find('.save-attributes-button');
this.$saveAttributesButton.on('click', () => this.save());
this.$errors = this.$widget.find('.attribute-errors');
}
addNewAttribute(e) {
contextMenuService.show({
x: e.pageX,
y: e.pageY,
orientation: 'left',
items: [
{title: `Add new label <kbd data-command="addNewLabel"></kbd>`, command: "addNewLabel", uiIcon: "hash"},
{title: `Add new relation <kbd data-command="addNewRelation"></kbd>`, command: "addNewRelation", uiIcon: "transfer"},
{title: "----"},
{title: "Add new label definition", command: "addNewLabelDefinition", uiIcon: "empty"},
{title: "Add new relation definition", command: "addNewRelationDefinition", uiIcon: "empty"},
],
selectMenuItemHandler: ({command}) => this.handleAddNewAttributeCommand(command)
});
}
// triggered from keyboard shortcut
addNewLabelEvent({ntxId}) {
if (this.isNoteContext(ntxId)) {
this.handleAddNewAttributeCommand('addNewLabel');
}
}
// triggered from keyboard shortcut
addNewRelationEvent({ntxId}) {
if (this.isNoteContext(ntxId)) {
this.handleAddNewAttributeCommand('addNewRelation');
}
}
async handleAddNewAttributeCommand(command) {
const attrs = this.parseAttributes();
if (!attrs) {
return;
}
let type, name, value;
if (command === 'addNewLabel') {
type = 'label';
name = 'myLabel';
value = '';
} else if (command === 'addNewRelation') {
type = 'relation';
name = 'myRelation';
value = '';
} else if (command === 'addNewLabelDefinition') {
type = 'label';
name = 'label:myLabel';
value = 'promoted,single,text';
} else if (command === 'addNewRelationDefinition') {
type = 'label';
name = 'relation:myRelation';
value = 'promoted,single';
} else {
return;
}
attrs.push({
type,
name,
value,
isInheritable: false
});
await this.renderOwnedAttributes(attrs, false);
this.$editor.scrollTop(this.$editor[0].scrollHeight);
const rect = this.$editor[0].getBoundingClientRect();
setTimeout(() => {
// showing a little bit later because there's a conflict with outside click closing the attr detail
this.attributeDetailWidget.showAttributeDetail({
allAttributes: attrs,
attribute: attrs[attrs.length - 1],
isOwned: true,
x: (rect.left + rect.right) / 2,
y: rect.bottom,
focus: 'name'
});
}, 100);
}
async save() {
const attributes = this.parseAttributes();
if (attributes) {
await server.put(`notes/${this.noteId}/attributes`, attributes, this.componentId);
this.$saveAttributesButton.fadeOut();
// blink the attribute text to give visual hint that save has been executed
this.$editor.css('opacity', 0);
// revert back
setTimeout(() => this.$editor.css('opacity', 1), 100);
}
}
parseAttributes() {
try {
const attrs = attributesParser.lexAndParse(this.getPreprocessedData());
return attrs;
}
catch (e) {
this.$errors.text(e.message).slideDown();
}
}
getPreprocessedData() {
const str = this.textEditor.getData()
.replace(/<a[^>]+href="(#[A-Za-z0-9/]*)"[^>]*>[^<]*<\/a>/g, "$1")
.replace(/&nbsp;/g, " "); // otherwise .text() below outputs non-breaking space in unicode
return $("<div>").html(str).text();
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
this.$widget.show();
this.$editor.on("click", e => this.handleEditorClick(e));
this.textEditor = await BalloonEditor.create(this.$editor[0], editorConfig);
this.textEditor.model.document.on('change:data', () => this.dataChanged());
this.textEditor.editing.view.document.on('enter', (event, data) => {
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
data.preventDefault();
event.stop();
}, {priority: 'high'});
// disable spellcheck for attribute editor
this.textEditor.editing.view.change(writer => writer.setAttribute('spellcheck', 'false', this.textEditor.editing.view.document.getRoot()));
//await import(/* webpackIgnore: true */'../../libraries/ckeditor/inspector.js');
//CKEditorInspector.attach(this.textEditor);
}
dataChanged() {
if (this.lastSavedContent === this.textEditor.getData()) {
this.$saveAttributesButton.fadeOut();
}
else {
this.$saveAttributesButton.fadeIn();
}
if (this.$errors.is(":visible")) {
// using .hide() instead of .slideUp() since this will also hide the error after confirming
// mention for relation name which suits up. When using.slideUp() error will appear and the slideUp which is weird
this.$errors.hide();
}
}
async handleEditorClick(e) {
const pos = this.textEditor.model.document.selection.getFirstPosition();
if (pos && pos.textNode && pos.textNode.data) {
const clickIndex = this.getClickIndex(pos);
let parsedAttrs;
try {
parsedAttrs = attributesParser.lexAndParse(this.getPreprocessedData(), true);
}
catch (e) {
// the input is incorrect because user messed up with it and now needs to fix it manually
return null;
}
let matchedAttr = null;
for (const attr of parsedAttrs) {
if (clickIndex > attr.startIndex && clickIndex <= attr.endIndex) {
matchedAttr = attr;
break;
}
}
setTimeout(() => {
if (matchedAttr) {
this.$editor.tooltip('hide');
this.attributeDetailWidget.showAttributeDetail({
allAttributes: parsedAttrs,
attribute: matchedAttr,
isOwned: true,
x: e.pageX,
y: e.pageY
});
}
else {
this.showHelpTooltip();
}
}, 100);
}
else {
this.showHelpTooltip();
}
}
showHelpTooltip() {
this.attributeDetailWidget.hide();
this.$editor.tooltip({
trigger: 'focus',
html: true,
title: HELP_TEXT,
placement: 'bottom',
offset: "0,30"
});
this.$editor.tooltip('show');
}
getClickIndex(pos) {
let clickIndex = pos.offset - pos.textNode.startOffset;
let curNode = pos.textNode;
while (curNode.previousSibling) {
curNode = curNode.previousSibling;
if (curNode.name === 'reference') {
clickIndex += curNode._attrs.get('notePath').length + 1;
} else {
clickIndex += curNode.data.length;
}
}
return clickIndex;
}
async loadReferenceLinkTitle(noteId, $el) {
const note = await froca.getNote(noteId, true);
let title;
if (!note) {
title = '[missing]';
}
else {
title = note.isDeleted ? `${note.title} (deleted)` : note.title;
}
$el.text(title);
}
async refreshWithNote(note) {
await this.renderOwnedAttributes(note.getOwnedAttributes(), true);
}
async renderOwnedAttributes(ownedAttributes, saved) {
ownedAttributes = ownedAttributes.filter(oa => !oa.isDeleted);
let htmlAttrs = (await attributeRenderer.renderAttributes(ownedAttributes, true)).html();
if (htmlAttrs.length > 0) {
htmlAttrs += "&nbsp;";
}
this.textEditor.setData(htmlAttrs);
if (saved) {
this.lastSavedContent = this.textEditor.getData();
this.$saveAttributesButton.fadeOut(0);
}
}
async createNoteForReferenceLink(title) {
const {note} = await noteCreateService.createNote(this.notePath, {
activate: false,
title: title
});
return treeService.getSomeNotePath(note);
}
async updateAttributeList(attributes) {
await this.renderOwnedAttributes(attributes, false);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes(this.componentId).find(attr => attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}