diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb68fdc81cf6f6a86971be70c321eaea3750f1f..c295a5fc637112645189759f3267859385514518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `BUILD_FOOTER_SOURCES_HREF` * `BUILD_FOOTER_LICENCE_HREF` See `build.properties.d/00_default.properties` for more information +* Start editing an entity/creating a new record directly by adding an `#edit` or + `#new_record` URI fragment, respectively. +* Optional WYSIWYG editor with markdown output for text properties. Controled by + the `BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT` build variable which is set do + `DISABLED` by default. ### Changed (for changes in existing functionality) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index c6043d09d049b91cfc0a03560970537feb8bbc06..f3e1d1706dd8dd9bdbdab16377c2c75ed0299f7f 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -24,5 +24,12 @@ * proj4js-2.5.0 * Proj4Leaflet-1.0.1 +## For CKEditor (WYSIWYG editor in edit mode) + +* we're using a custom-built ckeditor 31.0.0 from + https://ckeditor.com/ckeditor-5/online-builder/ with a customized set of + editor plugins. Please refer to the `package.json` within + `libs/ckeditor...zip` for a full list of said plugins. + ## For testing * qunit-2.9.2 diff --git a/Makefile b/Makefile index c820cfb159d9b63518dff445883615e67fbd9289..5cca686319c617006007e4884cfb269796ac7af4 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) TEST_SSS_DIR =$(abspath test/server_side_scripting) -LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js +LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js js/ckeditor.js TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -305,6 +305,9 @@ $(LIBS_DIR)/js/utif.js: unzip $(LIBS_DIR)/js $(LIBS_DIR)/js/qrcode.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/qrcode-1.4.4/qrcode.min.js $@ +$(LIBS_DIR)/js/ckeditor.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/ckeditor5-31.0.0-k356w86hp13l/build/ckeditor.js $@ + $(addprefix $(LIBS_DIR)/, js css): mkdir $@ || true diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index db01885d1494ffd3f07eecfa2e6f2d26f56e3705..535a6c846a5c39c48937dee43f087501f43285ae 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -60,6 +60,8 @@ BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM=CaosDB BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED BUILD_EXT_REFERENCES_CUSTOM_RESOLVER=person_reference +BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT=DISABLED + ############################################################################## # Navbar properties ############################################################################## @@ -173,4 +175,6 @@ MODULE_DEPENDENCIES=( qrcode.js ext_qrcode.js form_panel.js + ckeditor.js + ext_editmode_wysiwyg_text.js ) diff --git a/libs/ckeditor5-31.0.0-k356w86hp13l.zip b/libs/ckeditor5-31.0.0-k356w86hp13l.zip new file mode 100644 index 0000000000000000000000000000000000000000..71523482a2bdfa7c0a9776eeed7aa7be010e053b Binary files /dev/null and b/libs/ckeditor5-31.0.0-k356w86hp13l.zip differ diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index 359bfac8481f5edfa4a51b7b1c22d31240b1312e..284aaef65e4f6fe89d0a3e9ee4b6690b8ba38ce9 100644 --- a/src/core/js/edit_mode.js +++ b/src/core/js/edit_mode.js @@ -76,11 +76,52 @@ var edit_mode = new function () { } } + this.has_edit_fragment = function () { + const fragment = window.location.hash.substr(1); + return fragment === "edit"; + } + + this.has_new_record_fragment = function () { + const fragment = window.location.hash.substr(1); + return fragment === "new_record"; + } + this._init = function () { var target = $("#top-navbar").find("ul").first(); this.add_edit_mode_button(target, edit_mode.toggle_edit_mode); + + var after_setup_callback = () => {} // do nothing + if (this.has_edit_fragment()) { + // find first entity + const first_entity = $(".caosdb-entity-panel")[0]; + if (first_entity) { + window.localStorage["edit_mode"] = true; + after_setup_callback = () => { + logger.debug("Edit this entity after #edit in the uri", first_entity); + edit_mode.edit(first_entity); + } + } + } else if (this.has_new_record_fragment()) { + for (let entity of $(".caosdb-entity-panel")) { + // find first record type + if (getEntityRole(entity) === "RecordType") { + window.localStorage["edit_mode"] = true; + const new_record = edit_mode.create_new_record(getEntityID(entity)); + after_setup_callback = () => { + logger.debug("Create a new record after #new_record in the uri", entity); + new_record.then((new_record) => { + edit_mode.app.newEntity(new_record); + }, edit_mode.handle_error); + } + break; + } + } + } + + // intialize the edit mode panel and add all necessary buttons if the edit mode is if (this.is_edit_mode()) { - edit_mode.enter_edit_mode(); + + edit_mode.enter_edit_mode().then(after_setup_callback); edit_mode.toggle_edit_panel(); // This is for the very specific case of reloading the // page while the edit mode is active on small screens @@ -88,6 +129,9 @@ var edit_mode = new function () { $(".caosdb-edit-min-width-warning").addClass("d-block"); } $('.caosdb-f-edit').css("transition", "top 1s"); + + // add drag-n-drop listener (needed for the edit_mode toolbox). + edit_mode.init_dragable(); } @@ -444,9 +488,6 @@ var edit_mode = new function () { file_path = getEntityPath(entity_form); file_checksum = getEntityChecksum(entity_form); file_size = getEntitySize(entity_form); - console.log(file_path); - console.log(file_checksum); - console.log(file_size); } return createEntityXML( entityRole, @@ -580,7 +621,6 @@ var edit_mode = new function () { $(".caosdb-f-btn-toggle-edit-mode").text("Leave Edit Mode"); edit_mode.init_tool_box(); - edit_mode.init_dragable(); var nextEditApp = editApp; if (typeof nextEditApp == "undefined") { @@ -639,6 +679,7 @@ var edit_mode = new function () { * the model given by model. */ this.init_tool_box = async function () { + // remove previously added model $(".caosdb-f-edit-mode-existing").remove() @@ -649,6 +690,12 @@ var edit_mode = new function () { removeAllWaitingNotifications(editPanel[0]); editPanel.children()[0].appendChild(model); + + if (edit_mode.app && edit_mode.app.entity && edit_mode.app.entity.parentElement && (edit_mode.app.state === "changed")) { + // an entity is being editted + $(".caosdb-f-edit-mode-existing").toggleClass("d-none", false); + $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", true); + } } @@ -1485,6 +1532,7 @@ var edit_mode = new function () { init_drag_n_drop(); } app.onEnterInitial = async function (e) { + $(".caosdb-f-edit-mode-existing").toggleClass("d-none", true); $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", false); app.old = undefined; @@ -1559,6 +1607,7 @@ var edit_mode = new function () { }, edit_mode.handle_error); }; app.onEnterChanged = function (e) { + // show existing entities in toolbox $(".caosdb-f-edit-mode-existing").toggleClass("d-none", false); $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", true); diff --git a/src/core/js/ext_editmode_wysiwyg_text.js b/src/core/js/ext_editmode_wysiwyg_text.js new file mode 100644 index 0000000000000000000000000000000000000000..380d8b1ec298ed0fa5c68af50b8b0e9dad333b89 --- /dev/null +++ b/src/core/js/ext_editmode_wysiwyg_text.js @@ -0,0 +1,118 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 Florian Spreckelsen <f.spreckelsen@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +"use strict"; + +/** + * Replaces textareas in the edit mode by a wysiwyg editor + * + * @module ext_editmode_wysiwyg_text + * @version 0.1 + * + * @param jQuery - well-known library. + * @param log - singleton from loglevel library or javascript console. + * @param {class} ClassicEditor - ClassicEditor class from ckEditor + * @param {module} edit_mode - caosdb's edit-mode module + * @param {function} getPropertyElements - caosdb's function to extract the + * property HTML elements from an entity HTML element + * @param {function} getPropertyDatatype - caosdb's function to extract the + * data type from a property HTML element + * @param {function} getPropertyName - caosdb's function to extract the + * name from a property HTML element + */ +var ext_editmode_wysiwyg_text = function ($, logger, ClassicEditor, edit_mode, getPropertyElements, getPropertyDatatype, getPropertyName) { + + var insertEditorInProperty = async function (prop) { + if (!(getPropertyDatatype(prop) === 'TEXT')) { + // Ignore anything that isn't a list property, even LIST<TEXT> + return; + } + + try{ + const editor = await ClassicEditor + .create(prop.querySelector('textarea'), { + // use all plugins since we built the editor dependency to + // contain only those we need. + plugins: ClassicEditor.builtinPlugins, + // Markdown needs a header row so enforce this + table: { + defaultHeadings: { + rows: 1, + columns: 0 + }, + }, + }) + logger.debug('Initialized editor for ' + getPropertyName(prop)); + // Manually implement saving the data since edit mode is not + // a form to be submitted. + editor.model.document.on("change:data", (e) => { + editor.updateSourceElement(); + }); + } catch(error) { + logger.error(error.stack); + } + } + + var replaceTextAreas = function (entity) { + const properties = getPropertyElements(entity); + for (let prop of properties) { + // TODO(fspreck): This will be replaced by a whitelist of properties + // in the future. + if (getPropertyName(prop)) { + insertEditorInProperty(prop); + } + } + } + + var init = function () { + + // Insert an editor into all TEXT properties of the entity which is + // being edited. + document.body.addEventListener(edit_mode.start_edit.type, (e) => { + logger.debug('Replacing text areas ...'); + ext_editmode_wysiwyg_text.replaceTextAreas(e.target); + }, true); + + // Listen to added properties and replace the textarea if necessary + document.body.addEventListener(edit_mode.property_added.type, (e) => { + logger.debug('Replacing textarea in ' + getPropertyName(e.target)); + ext_editmode_wysiwyg_text.insertEditorInProperty(e.target); + }, true) + + // Listen to properties, the data type of which has changed. Mainly + // because of change from list to scalar and vice versa. + document.body.addEventListener(edit_mode.property_data_type_changed.type, (e) => { + logger.debug('Re-rendering ' + getPropertyName(e.target)); + ext_editmode_wysiwyg_text.insertEditorInProperty(e.target); + }, true); + }; + + return { + init: init, + replaceTextAreas: replaceTextAreas, + insertEditorInProperty: insertEditorInProperty, + }; +}($, log.getLogger("ext_editmode_wysiwyg_text"), ClassicEditor, edit_mode, getPropertyElements, getPropertyDatatype, getPropertyName); + +$(document).ready(() => { + if ("${BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT}" == "ENABLED") { + caosdb_modules.register(ext_editmode_wysiwyg_text); + } +}); diff --git a/test/core/js/modules/ext_cosmetics.js.js b/test/core/js/modules/ext_cosmetics.js.js index d5d4df7f10a2859bcd7318680d4f6720aedc6127..969c8297b8b5cf85d0a668d7c30e8b0f45e34d4d 100644 --- a/test/core/js/modules/ext_cosmetics.js.js +++ b/test/core/js/modules/ext_cosmetics.js.js @@ -46,13 +46,16 @@ QUnit.test("linkify - https", function (assert) { ["this is other text https://link", 1], ["this is other text https://link and here comes another link https://link and more text", 2], ]; + for (let test_case of test_cases) { - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a[href='https://link']").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + container.append(text_value); + assert.equal($(container).find("a[href='https://link']").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a[href='https://link']").length, test_case[1], "link is present"); - text_value.remove(); + assert.equal($(container).find("a[href='https://link']").length, test_case[1], "link is present"); + container.remove(); } }); @@ -66,22 +69,26 @@ QUnit.test("linkify - http", function (assert) { ["this is other text http://link and here comes another link http://link and more text", 2], ]; for (let test_case of test_cases) { - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a[href='http://link']").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(container).append(text_value); + assert.equal($(container).find("a[href='http://link']").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a[href='http://link']").length, test_case[1], "link is present"); - text_value.remove(); + assert.equal($(container).find("a[href='http://link']").length, test_case[1], "link is present"); + container.remove(); } }); QUnit.test("linkify cut-off (40)", function (assert) { - var test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); + $(container).append(text_value); + assert.equal($(container).find("a").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a").length, 1, "link is present"); - assert.equal($(text_value).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); - text_value.remove(); -}); \ No newline at end of file + assert.equal($(container).find("a").length, 1, "link is present"); + assert.equal($(container).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); + container.remove(); +});