From 99d1db068a97b3cbdf9c4045475ea0930b8b1d2c Mon Sep 17 00:00:00 2001 From: Marc Schlaeppi Date: Wed, 15 Jul 2026 03:20:49 +0200 Subject: [PATCH 01/20] Replace legacy Ace editors --- e2e/tests/administrator/case/ioc.spec.js | 5 +- .../blueprints/pages/case/templates/case.html | 1 - .../pages/case/templates/case_assets.html | 3 +- .../pages/case/templates/case_notes_v2.html | 1 - .../manage/templates/manage_attributes.html | 6 +- .../templates/manage_case_templates.html | 5 +- .../manage/templates/manage_modules.html | 5 +- .../templates/modal_update_parameter.html | 12 +- .../pages/search/templates/search.html | 6 +- source/app/templates/layouts/default.html | 7 +- .../templates/layouts/default_centered.html | 7 +- source/app/templates/layouts/default_ext.html | 7 +- ui/package-lock.json | 8 +- ui/package.json | 5 +- ui/public/assets/css/atlantis.css | 6 +- .../ace/src-noconflict/theme-iris_night.js | 114 ---- ui/src/lib/milkdown_overrides.css | 20 + ui/src/pages/alerts.js | 27 +- ui/src/pages/case.ioc.js | 6 +- ui/src/pages/case.rfiles.js | 4 +- ui/src/pages/case.tasks.js | 4 +- ui/src/pages/case.timeline.js | 9 +- ui/src/pages/comments.js | 4 +- ui/src/pages/common.js | 144 +---- ui/src/pages/datastore.js | 5 +- ui/src/pages/manage.attributes.js | 5 +- ui/src/pages/manage.case.templates.js | 17 +- ui/src/pages/manage.modules.js | 2 +- ui/src/pages/milkdown_editor.js | 106 +--- ui/src/pages/milkdown_shared.js | 97 ++++ ui/src/pages/unified_editor.js | 533 ++++++++++++++++++ ui/test/milkdown_shared.test.js | 39 ++ ui/vite.config.js | 4 - 33 files changed, 778 insertions(+), 446 deletions(-) delete mode 100644 ui/public/assets/js/plugin/ace/src-noconflict/theme-iris_night.js create mode 100644 ui/src/pages/milkdown_shared.js create mode 100644 ui/src/pages/unified_editor.js create mode 100644 ui/test/milkdown_shared.test.js diff --git a/e2e/tests/administrator/case/ioc.spec.js b/e2e/tests/administrator/case/ioc.spec.js index 0db39df0f..70c13d0c0 100644 --- a/e2e/tests/administrator/case/ioc.spec.js +++ b/e2e/tests/administrator/case/ioc.spec.js @@ -13,14 +13,17 @@ test.beforeEach(async({ page }) => { test('should be able to update IOC', async ({ page }) => { const iocValue = `IOC value - ${crypto.randomUUID()}`; + const iocDescription = `IOC description - ${crypto.randomUUID()}`; await page.getByRole('button', { name: 'Add IOC' }).click(); await page.getByRole('button', { name: 'None' }).click(); await page.getByRole('listbox').getByRole('option', { name: 'AS', exact: true }).click(); await page.getByLabel('IOC Value *').fill(iocValue); + await page.locator('#ioc_description .ProseMirror').fill(iocDescription); await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('link', { name: iocValue }).click(); + await expect(page.locator('#ioc_description .ProseMirror')).toContainText(iocDescription); const newIocValue = `IOC value - ${crypto.randomUUID()}`; await page.getByLabel('IOC Value *').fill(newIocValue); await page.getByRole('button', { name: 'Update' }).click(); @@ -89,4 +92,4 @@ test('should be able to update IOC custom attribute', async ({ page, rest }) => partial_overwrite: false } }) -}); \ No newline at end of file +}); diff --git a/source/app/blueprints/pages/case/templates/case.html b/source/app/blueprints/pages/case/templates/case.html index 7a10c821f..aa13f5f87 100644 --- a/source/app/blueprints/pages/case/templates/case.html +++ b/source/app/blueprints/pages/case/templates/case.html @@ -4,7 +4,6 @@ - {% endblock stylesheets %} {% block content %} {% if current_user.is_authenticated %} diff --git a/source/app/blueprints/pages/case/templates/case_assets.html b/source/app/blueprints/pages/case/templates/case_assets.html index a18096bab..11581b1b5 100644 --- a/source/app/blueprints/pages/case/templates/case_assets.html +++ b/source/app/blueprints/pages/case/templates/case_assets.html @@ -1,7 +1,6 @@ {% extends "layouts/default_ext.html" %} {% block title %} Case Assets {% endblock title %} {% block stylesheets %} {% include 'includes/header_case.html' %} - {% endblock stylesheets %} {% block content %} {% include 'includes/navigation_ext.html' %} @@ -148,4 +147,4 @@
Upload assets list (CSV format)
-{% endblock javascripts %} \ No newline at end of file +{% endblock javascripts %} diff --git a/source/app/blueprints/pages/case/templates/case_notes_v2.html b/source/app/blueprints/pages/case/templates/case_notes_v2.html index 4d58ab289..d77d150f0 100644 --- a/source/app/blueprints/pages/case/templates/case_notes_v2.html +++ b/source/app/blueprints/pages/case/templates/case_notes_v2.html @@ -1,7 +1,6 @@ {% extends "layouts/default_ext.html" %} {% block title %} Case notes {% endblock title %} {% block stylesheets %} {% include 'includes/header_case.html' %} - {% endblock stylesheets %} {% block content %} {% include 'includes/navigation_ext.html' %} diff --git a/source/app/blueprints/pages/manage/templates/manage_attributes.html b/source/app/blueprints/pages/manage/templates/manage_attributes.html index a87c06945..10ce7667a 100644 --- a/source/app/blueprints/pages/manage/templates/manage_attributes.html +++ b/source/app/blueprints/pages/manage/templates/manage_attributes.html @@ -74,9 +74,5 @@ {% endblock content %} {% block javascripts %} - - - - {% endblock javascripts %} \ No newline at end of file + {% endblock javascripts %} diff --git a/source/app/blueprints/pages/manage/templates/manage_case_templates.html b/source/app/blueprints/pages/manage/templates/manage_case_templates.html index 0c8355161..184429227 100644 --- a/source/app/blueprints/pages/manage/templates/manage_case_templates.html +++ b/source/app/blueprints/pages/manage/templates/manage_case_templates.html @@ -86,8 +86,5 @@ {% endblock content %} {% block javascripts %} - - - {% endblock javascripts %} \ No newline at end of file + {% endblock javascripts %} diff --git a/source/app/blueprints/pages/manage/templates/manage_modules.html b/source/app/blueprints/pages/manage/templates/manage_modules.html index 50d88f9d9..cdda4a463 100644 --- a/source/app/blueprints/pages/manage/templates/manage_modules.html +++ b/source/app/blueprints/pages/manage/templates/manage_modules.html @@ -135,9 +135,6 @@ {% endblock content %} {% block javascripts %} - - - {% endblock javascripts %} \ No newline at end of file + {% endblock javascripts %} diff --git a/source/app/blueprints/pages/manage/templates/modal_update_parameter.html b/source/app/blueprints/pages/manage/templates/modal_update_parameter.html index 33061d12b..f84cb3029 100644 --- a/source/app/blueprints/pages/manage/templates/modal_update_parameter.html +++ b/source/app/blueprints/pages/manage/templates/modal_update_parameter.html @@ -46,19 +46,17 @@

Update {{ parameter["param_human_name"] }} of {{ mod_name }}

\ No newline at end of file + diff --git a/source/app/blueprints/pages/search/templates/search.html b/source/app/blueprints/pages/search/templates/search.html index 2eb14b829..1541c9d90 100644 --- a/source/app/blueprints/pages/search/templates/search.html +++ b/source/app/blueprints/pages/search/templates/search.html @@ -153,11 +153,7 @@ - - - -{% endblock javascripts %} \ No newline at end of file +{% endblock javascripts %} diff --git a/source/app/templates/layouts/default.html b/source/app/templates/layouts/default.html index 075e6978c..5a9be16ef 100644 --- a/source/app/templates/layouts/default.html +++ b/source/app/templates/layouts/default.html @@ -27,6 +27,7 @@ + {% block stylesheets %}{% endblock stylesheets %} @@ -96,9 +97,7 @@ - - + @@ -115,4 +114,4 @@ {% block javascripts %}{% endblock javascripts %} - \ No newline at end of file + diff --git a/source/app/templates/layouts/default_centered.html b/source/app/templates/layouts/default_centered.html index 47720bd2c..0acd32578 100644 --- a/source/app/templates/layouts/default_centered.html +++ b/source/app/templates/layouts/default_centered.html @@ -26,6 +26,7 @@ + @@ -86,9 +87,7 @@ - - + @@ -105,4 +104,4 @@ {% block javascripts %}{% endblock javascripts %} - \ No newline at end of file + diff --git a/source/app/templates/layouts/default_ext.html b/source/app/templates/layouts/default_ext.html index ce5464cb5..8f8bf7c8a 100644 --- a/source/app/templates/layouts/default_ext.html +++ b/source/app/templates/layouts/default_ext.html @@ -26,6 +26,7 @@ + {% block stylesheets %}{% endblock stylesheets %} @@ -82,9 +83,7 @@ - - + @@ -100,4 +99,4 @@ {% block javascripts %}{% endblock javascripts %} - \ No newline at end of file + diff --git a/ui/package-lock.json b/ui/package-lock.json index 1c5f02642..ec52c3087 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "iris-web-ui", "version": "0.0.0", "dependencies": { + "@codemirror/autocomplete": "6.20.3", "@codemirror/lang-markdown": "6.5.0", "@codemirror/language-data": "6.5.2", "@codemirror/state": "6.6.0", @@ -15,7 +16,6 @@ "@lezer/highlight": "1.2.3", "@milkdown/crepe": "^7.21.2", "@milkdown/plugin-collab": "7.21.2", - "ace-builds": "^1.4.9", "bootstrap": "^4.0.0", "bootstrap-slider": "^11.0.2", "chart.js": "^2.7.2", @@ -2624,12 +2624,6 @@ "node": ">= 0.6" } }, - "node_modules/ace-builds": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz", - "integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==", - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/ui/package.json b/ui/package.json index c60c02c26..af2d71b6f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview", "watch": "vite build --mode development --watch", + "test": "node --test", "lint": "eslint ./src" }, "devDependencies": { @@ -24,6 +25,7 @@ "vite-plugin-static-copy": "^1.0.6" }, "dependencies": { + "@codemirror/autocomplete": "6.20.3", "@codemirror/lang-markdown": "6.5.0", "@codemirror/language-data": "6.5.2", "@codemirror/state": "6.6.0", @@ -31,7 +33,6 @@ "@lezer/highlight": "1.2.3", "@milkdown/crepe": "^7.21.2", "@milkdown/plugin-collab": "7.21.2", - "ace-builds": "^1.4.9", "bootstrap": "^4.0.0", "bootstrap-slider": "^11.0.2", "chart.js": "^2.7.2", @@ -50,8 +51,8 @@ "vis": "^4.21.0-EOL", "webfontloader": "^1.6.16", "xss": "^1.0.15", - "y-protocols": "^1.0.7", "y-prosemirror": "1.3.7", + "y-protocols": "^1.0.7", "y-websocket": "^3.0.0", "yjs": "^13.6.31" } diff --git a/ui/public/assets/css/atlantis.css b/ui/public/assets/css/atlantis.css index e93b39c8b..d2d4605bd 100644 --- a/ui/public/assets/css/atlantis.css +++ b/ui/public/assets/css/atlantis.css @@ -15923,10 +15923,6 @@ td.dt-nowrap { white-space: nowrap } text-overflow: ellipsis; } -.ace_editor { - border-radius: .3rem !important; -} - #protagonists_form_group { background-color: #f5f5f5bf; border-radius: 14px; @@ -16047,4 +16043,4 @@ td.dt-nowrap { white-space: nowrap } .copy-value:hover .copy-btn { display: inline; - } \ No newline at end of file + } diff --git a/ui/public/assets/js/plugin/ace/src-noconflict/theme-iris_night.js b/ui/public/assets/js/plugin/ace/src-noconflict/theme-iris_night.js deleted file mode 100644 index e07abc625..000000000 --- a/ui/public/assets/js/plugin/ace/src-noconflict/theme-iris_night.js +++ /dev/null @@ -1,114 +0,0 @@ -ace.define("ace/theme/iris_night",["require","exports","module","ace/lib/dom"], function(require, exports, module) { - -exports.isDark = true; -exports.cssClass = "ace-iris-night"; -exports.cssText = ".ace-iris-night .ace_gutter {\ -background: #181e2f;\ -color: #C5C8C6\ -}\ -.ace-iris-night .ace_print-margin {\ -width: 1px;\ -background: #181e2f\ -}\ -.ace-iris-night {\ -background-color: #1b2233;\ -color: #C5C8C6\ -}\ -.ace-iris-night .ace_cursor {\ -color: #AEAFAD\ -}\ -.ace-iris-night .ace_marker-layer .ace_selection {\ -background: #373B41\ -}\ -.ace-iris-night.ace_multiselect .ace_selection.ace_start {\ -box-shadow: 0 0 3px 0px #1b2233;\ -}\ -.ace-iris-night .ace_marker-layer .ace_step {\ -background: rgb(102, 82, 0)\ -}\ -.ace-iris-night .ace_marker-layer .ace_bracket {\ -margin: -1px 0 0 -1px;\ -border: 1px solid #4B4E55\ -}\ -.ace-iris-night .ace_marker-layer .ace_active-line {\ -background: #151926\ -}\ -.ace-iris-night .ace_gutter-active-line {\ -background-color: #151926\ -}\ -.ace-iris-night .ace_marker-layer .ace_selected-word {\ -border: 1px solid #373B41\ -}\ -.ace-iris-night .ace_invisible {\ -color: #4B4E55\ -}\ -.ace-iris-night .ace_keyword,\ -.ace-iris-night .ace_meta,\ -.ace-iris-night .ace_storage,\ -.ace-iris-night .ace_storage.ace_type,\ -.ace-iris-night .ace_support.ace_type {\ -color: #B294BB\ -}\ -.ace-iris-night .ace_keyword.ace_operator {\ -color: #8ABEB7\ -}\ -.ace-iris-night .ace_constant.ace_character,\ -.ace-iris-night .ace_constant.ace_language,\ -.ace-iris-night .ace_constant.ace_numeric,\ -.ace-iris-night .ace_keyword.ace_other.ace_unit,\ -.ace-iris-night .ace_support.ace_constant,\ -.ace-iris-night .ace_variable.ace_parameter {\ -color: #DE935F\ -}\ -.ace-iris-night .ace_constant.ace_other {\ -color: #CED1CF\ -}\ -.ace-iris-night .ace_invalid {\ -color: #CED2CF;\ -background-color: #DF5F5F\ -}\ -.ace-iris-night .ace_invalid.ace_deprecated {\ -color: #CED2CF;\ -background-color: #B798BF\ -}\ -.ace-iris-night .ace_fold {\ -background-color: #81A2BE;\ -border-color: #C5C8C6\ -}\ -.ace-iris-night .ace_entity.ace_name.ace_function,\ -.ace-iris-night .ace_support.ace_function,\ -.ace-iris-night .ace_variable {\ -color: #81A2BE\ -}\ -.ace-iris-night .ace_support.ace_class,\ -.ace-iris-night .ace_support.ace_type {\ -color: #F0C674\ -}\ -.ace-iris-night .ace_heading,\ -.ace-iris-night .ace_markup.ace_heading,\ -.ace-iris-night .ace_string {\ -color: #B5BD68\ -}\ -.ace-iris-night .ace_entity.ace_name.ace_tag,\ -.ace-iris-night .ace_entity.ace_other.ace_attribute-name,\ -.ace-iris-night .ace_meta.ace_tag,\ -.ace-iris-night .ace_string.ace_regexp,\ -.ace-iris-night .ace_variable {\ -color: #CC6666\ -}\ -.ace-iris-night .ace_comment {\ -color: #969896\ -}\ -.ace-iris-night .ace_indent-guide {\ -background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC) right repeat-y\ -}"; - -var dom = require("../lib/dom"); -dom.importCssString(exports.cssText, exports.cssClass); -}); (function() { - ace.require(["ace/theme/iris_night"], function(m) { - if (typeof module == "object" && typeof exports == "object" && module) { - module.exports = m; - } - }); - })(); \ No newline at end of file diff --git a/ui/src/lib/milkdown_overrides.css b/ui/src/lib/milkdown_overrides.css index 9602d49ac..a352c514f 100644 --- a/ui/src/lib/milkdown_overrides.css +++ b/ui/src/lib/milkdown_overrides.css @@ -14,6 +14,26 @@ background: rgba(0, 0, 0, 0.16); } +.iris-markdown-editor[data-theme="dark"] .milkdown { + --crepe-color-background: #1a2035; + --crepe-color-on-background: #e0e0e0; + --crepe-color-surface: #1f283e; + --crepe-color-surface-low: #151b2b; + --crepe-color-on-surface: #cac7c7; + --crepe-color-on-surface-variant: #8b92a9; + --crepe-color-outline: #5c6370; + --crepe-color-primary: #66aaff; + --crepe-color-secondary: #2f374b; + --crepe-color-on-secondary: #e0e0e0; + --crepe-color-inverse: #1f283e; + --crepe-color-on-inverse: #f0f0f0; + --crepe-color-inline-code: #ff7b72; + --crepe-color-error: #f85149; + --crepe-color-hover: #1f283e; + --crepe-color-selected: #264f78; + --crepe-color-inline-area: #2f374b; +} + /* Centered grip indicator so it reads as a draggable handle. */ .milkdown .milkdown-image-block > .image-wrapper > .image-resize-handle::after { content: ""; diff --git a/ui/src/pages/alerts.js b/ui/src/pages/alerts.js index 83dce6e64..632aee222 100644 --- a/ui/src/pages/alerts.js +++ b/ui/src/pages/alerts.js @@ -1587,10 +1587,12 @@ function resetSavedFilters(queryParams = null, replaceState = true) { function showEnrichment(enrichment) { - const ace = get_new_ace_editor('enrichmentData', null, - null, null, null, true, false); - ace.session.setMode("ace/mode/json"); - ace.setValue(JSON.stringify(enrichment, null, 4), -1); + const enrichmentEditor = create_iris_code_editor('enrichmentData', { + minLines: 8, + readOnly: true, + }); + enrichmentEditor.setMode("json"); + enrichmentEditor.setValue(JSON.stringify(enrichment, null, 4)); } function delete_alert(alert_id) { @@ -2139,13 +2141,8 @@ $(document).ready(function () { }); } - editor = ace.edit('custom_conditions'); - if ($("#custom_conditions").attr("data-theme") != "dark") { - editor.setTheme("ace/theme/tomorrow"); - } else { - editor.setTheme("ace/theme/iris_night"); - } - editor.session.setMode("ace/mode/json"); + editor = create_iris_code_editor('custom_conditions'); + editor.setMode("json"); editor.renderer.setShowGutter(true); editor.setOption("showLineNumbers", true); editor.setOption("showPrintMargin", false); @@ -2161,9 +2158,6 @@ $(document).ready(function () { editor.setOption("enableBasicAutocompletion", true); editor.setOption("enableLiveAutocompletion", true); - // Use the langTools from ACE for autocompletion - let langTools = ace.require("ace/ext/language_tools"); - // Define a custom completer let customCompleter = { getCompletions: function(editor, session, pos, prefix, callback) { @@ -2204,8 +2198,7 @@ $(document).ready(function () { } }; - // Add the custom completer to ACE - langTools.addCompleter(customCompleter); + editor.setCompletions([customCompleter]); fetchSavedFilters() .then(() => { @@ -2252,4 +2245,4 @@ $(document).ready(function () { badge.attr('title', 'New alerts available'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/pages/case.ioc.js b/ui/src/pages/case.ioc.js index d111d021d..735e27bcc 100644 --- a/ui/src/pages/case.ioc.js +++ b/ui/src/pages/case.ioc.js @@ -41,7 +41,7 @@ function add_ioc() { return false; } - g_ioc_desc_editor = get_new_ace_editor('ioc_description', 'ioc_desc_content', 'target_ioc_desc', + g_ioc_desc_editor = get_new_markdown_editor('ioc_description', 'ioc_desc_content', 'target_ioc_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -185,7 +185,7 @@ function edit_ioc(ioc_id) { } g_ioc_id = ioc_id; - g_ioc_desc_editor = get_new_ace_editor('ioc_description', 'ioc_desc_content', 'target_ioc_desc', + g_ioc_desc_editor = get_new_markdown_editor('ioc_description', 'ioc_desc_content', 'target_ioc_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -468,4 +468,4 @@ $(document).ready(function(){ if (shared_id) { edit_ioc(shared_id); } -}); \ No newline at end of file +}); diff --git a/ui/src/pages/case.rfiles.js b/ui/src/pages/case.rfiles.js index 5511a894e..fefdce8f4 100644 --- a/ui/src/pages/case.rfiles.js +++ b/ui/src/pages/case.rfiles.js @@ -50,7 +50,7 @@ function add_modal_rfile() { return false; } - g_evidence_desc_editor = get_new_ace_editor('evidence_description', 'evidence_desc_content', 'target_evidence_desc', + g_evidence_desc_editor = get_new_markdown_editor('evidence_description', 'evidence_desc_content', 'target_evidence_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -207,7 +207,7 @@ function edit_rfiles(rfiles_id) { return false; } - g_evidence_desc_editor = get_new_ace_editor('evidence_description', 'evidence_desc_content', 'target_evidence_desc', + g_evidence_desc_editor = get_new_markdown_editor('evidence_description', 'evidence_desc_content', 'target_evidence_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); diff --git a/ui/src/pages/case.tasks.js b/ui/src/pages/case.tasks.js index e645a3cd6..e4da04bb6 100644 --- a/ui/src/pages/case.tasks.js +++ b/ui/src/pages/case.tasks.js @@ -27,7 +27,7 @@ function add_task() { return false; } - g_task_desc_editor = get_new_ace_editor('task_description', 'task_desc_content', 'target_task_desc', + g_task_desc_editor = get_new_markdown_editor('task_description', 'task_desc_content', 'target_task_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -160,7 +160,7 @@ function edit_task(id) { g_task_id = id; - g_task_desc_editor = get_new_ace_editor('task_description', 'task_desc_content', 'target_task_desc', + g_task_desc_editor = get_new_markdown_editor('task_description', 'task_desc_content', 'target_task_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); diff --git a/ui/src/pages/case.timeline.js b/ui/src/pages/case.timeline.js index 1320f15c3..e83e30674 100644 --- a/ui/src/pages/case.timeline.js +++ b/ui/src/pages/case.timeline.js @@ -28,7 +28,7 @@ function add_event(parent_event_id = null) { return false; } - g_event_desc_editor = get_new_ace_editor('event_description', 'event_desc_content', 'target_event_desc', + g_event_desc_editor = get_new_markdown_editor('event_description', 'event_desc_content', 'target_event_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -209,7 +209,7 @@ function edit_event(id) { } g_event_id = id; - g_event_desc_editor = get_new_ace_editor('event_description', 'event_desc_content', 'target_event_desc', + g_event_desc_editor = get_new_markdown_editor('event_description', 'event_desc_content', 'target_event_desc', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -1373,14 +1373,13 @@ $(document).ready(function(){ selector_active = false; - tm_filter = ace.edit("timeline_filtering", + tm_filter = create_iris_code_editor("timeline_filtering", { autoScrollEditorIntoView: true, minLines: 1, maxLines: 5 }); - tm_filter.setTheme("ace/theme/tomorrow"); - tm_filter.session.setMode("ace/mode/json"); + tm_filter.setMode("json"); tm_filter.renderer.setShowGutter(false); tm_filter.setShowPrintMargin(false); tm_filter.renderer.setScrollMargin(10, 10); diff --git a/ui/src/pages/comments.js b/ui/src/pages/comments.js index 64836e20a..cfbf1a02c 100644 --- a/ui/src/pages/comments.js +++ b/ui/src/pages/comments.js @@ -21,7 +21,7 @@ function comment_element(element_id, element_type, is_alert=false) { $('#modal_comment').modal('show'); - g_comment_desc_editor = get_new_ace_editor('comment_message', 'comment_content', 'target_comment_content', + g_comment_desc_editor = get_new_markdown_editor('comment_message', 'comment_content', 'target_comment_content', function() { $('#last_saved').addClass('btn-danger').removeClass('btn-success'); $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation"); @@ -259,4 +259,4 @@ function load_comments(element_id, element_type, comment_id, do_notification, is } } }); -} \ No newline at end of file +} diff --git a/ui/src/pages/common.js b/ui/src/pages/common.js index a8039c471..4d69ebfb9 100644 --- a/ui/src/pages/common.js +++ b/ui/src/pages/common.js @@ -836,124 +836,38 @@ function get_row_value(row, column) { var iClassWhiteList = ['fa-solid fa-tags','fa-solid fa-tag', 'fa-solid fa-bell', 'fa-solid fa-virus-covid text-danger mr-1', 'fa-solid fa-file-shield text-success mr-1', 'fa-regular fa-file mr-1', 'fa-solid fa-lock text-success mr-1'] -function get_new_ace_editor(anchor_id, content_anchor, target_anchor, onchange_callback, do_save, readonly, live_preview) { - var editor = ace.edit(anchor_id); - if ($("#"+anchor_id).attr("data-theme") != "dark") { - editor.setTheme("ace/theme/tomorrow"); - } else { - editor.setTheme("ace/theme/iris_night"); - } - editor.session.setMode("ace/mode/markdown"); - if (readonly !== undefined) { - editor.setReadOnly(readonly); - } - editor.renderer.setShowGutter(true); - editor.setOption("showLineNumbers", true); - editor.setOption("showPrintMargin", false); - editor.setOption("displayIndentGuides", true); - editor.setOption("maxLines", "Infinity"); +function get_new_markdown_editor(anchor_id, content_anchor, target_anchor, onchange_callback, do_save, readonly, live_preview) { + var editor = create_iris_markdown_editor(anchor_id, {readOnly: Boolean(readonly)}); editor.setOption("minLines", "2"); - editor.setOption("autoScrollEditorIntoView", true); - editor.session.setUseWrapMode(true); - editor.setOption("indentedSoftWrap", false); - editor.renderer.setScrollMargin(8, 5) - editor.setOption("enableBasicAutocompletion", true); if (do_save !== undefined && do_save !== null) { - editor.commands.addCommand({ - name: 'save', - bindKey: {win: "Ctrl-S", "mac": "Cmd-S"}, - exec: function(editor) { - do_save() + document.getElementById(anchor_id).addEventListener('keydown', function (event) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + do_save(); } }); } - editor.commands.addCommand({ - name: 'bold', - bindKey: {win: "Ctrl-B", "mac": "Cmd-B"}, - exec: function(editor) { - editor.insertSnippet('**${1:$SELECTION}**'); - } - }); - editor.commands.addCommand({ - name: 'italic', - bindKey: {win: "Ctrl-I", "mac": "Cmd-I"}, - exec: function(editor) { - editor.insertSnippet('*${1:$SELECTION}*'); - } - }); - editor.commands.addCommand({ - name: 'head_1', - bindKey: {win: "Ctrl-Shift-1", "mac": "Cmd-Shift-1"}, - exec: function(editor) { - editor.insertSnippet('# ${1:$SELECTION}'); - } - }); - editor.commands.addCommand({ - name: 'head_2', - bindKey: {win: "Ctrl-Shift-2", "mac": "Cmd-Shift-2"}, - exec: function(editor) { - editor.insertSnippet('## ${1:$SELECTION}'); - } - }); - editor.commands.addCommand({ - name: 'head_3', - bindKey: {win: "Ctrl-Shift-3", "mac": "Cmd-Shift-3"}, - exec: function(editor) { - editor.insertSnippet('### ${1:$SELECTION}'); - } - }); - editor.commands.addCommand({ - name: 'head_4', - bindKey: {win: "Ctrl-Shift-4", "mac": "Cmd-Shift-4"}, - exec: function(editor) { - editor.insertSnippet('#### ${1:$SELECTION}'); - } - }); - - editor.commands.addCommand({ - name: 'link', - bindKey: {win: "Ctrl-K", "mac": "Cmd-K"}, - exec: function(editor) { - editor.insertSnippet('[${1:$SELECTION}](url)'); + const updatePreview = function () { + if (onchange_callback !== undefined && onchange_callback !== null) { + onchange_callback(); } - }); - - editor.commands.addCommand({ - name: 'code', - bindKey: {win: "Ctrl-`", "mac": "Cmd-`"}, - exec: function(editor) { - editor.insertSnippet('```${1:$SELECTION}```') + if (live_preview === false || !content_anchor || !target_anchor) { + return; } - }); - - if (live_preview === undefined || live_preview === true) { - let textarea = $('#'+content_anchor); - // Remove any previous event handler - editor.getSession().off("change"); - - editor.getSession().on("change", function () { - if (onchange_callback !== undefined && onchange_callback !== null) { - onchange_callback(); - } - - textarea.text(editor.getSession().getValue()); - let target = document.getElementById(target_anchor); - let converter = get_showdown_convert(); - let html = converter.makeHtml(editor.getSession().getValue()); - target.innerHTML = do_md_filter_xss(html); - - }); - textarea.text(editor.getSession().getValue()); + let markdown = editor.getValue(); + $('#'+content_anchor).text(markdown); let target = document.getElementById(target_anchor); - let converter = get_showdown_convert(); - let html = converter.makeHtml(editor.getSession().getValue()); - target.innerHTML = do_md_filter_xss(html); - - } + if (target) { + let converter = get_showdown_convert(); + target.innerHTML = do_md_filter_xss(converter.makeHtml(markdown)); + } + }; + editor.on("change", updatePreview); + updatePreview(); return editor; } @@ -1096,22 +1010,8 @@ function get_editor_headers(editor_instance, save, edition_btn) { if (save === undefined || save === null) { save_html = ''; } - header = ` - ${save_html} -
-
-
H1
-
H2
-
H3
-
H4
-
-
-
-
-
-
- - ` + header = `${save_html} +
` return header; } diff --git a/ui/src/pages/datastore.js b/ui/src/pages/datastore.js index 4aa7548c4..5f010749d 100644 --- a/ui/src/pages/datastore.js +++ b/ui/src/pages/datastore.js @@ -2,14 +2,13 @@ var ds_filter; function load_datastore() { - ds_filter = ace.edit("ds_file_search", + ds_filter = create_iris_code_editor("ds_file_search", { autoScrollEditorIntoView: true, minLines: 1, maxLines: 5 }); - ds_filter.setTheme("ace/theme/tomorrow"); - ds_filter.session.setMode("ace/mode/json"); + ds_filter.setMode("json"); ds_filter.renderer.setShowGutter(false); ds_filter.setShowPrintMargin(false); ds_filter.renderer.setScrollMargin(10, 10); diff --git a/ui/src/pages/manage.attributes.js b/ui/src/pages/manage.attributes.js index 3bea6e72d..966f3fe54 100644 --- a/ui/src/pages/manage.attributes.js +++ b/ui/src/pages/manage.attributes.js @@ -72,13 +72,12 @@ function attribute_detail(attr_id) { return false; } - var editor = ace.edit("editor_detail", + var editor = create_iris_code_editor("editor_detail", { autoScrollEditorIntoView: true, minLines: 30, }); - editor.setTheme("ace/theme/tomorrow"); - editor.session.setMode("ace/mode/json"); + editor.setMode("json"); editor.renderer.setShowGutter(true); editor.setOption("showLineNumbers", true); editor.setOption("showPrintMargin", false); diff --git a/ui/src/pages/manage.case.templates.js b/ui/src/pages/manage.case.templates.js index 2286d2d66..5df95f61e 100644 --- a/ui/src/pages/manage.case.templates.js +++ b/ui/src/pages/manage.case.templates.js @@ -6,13 +6,12 @@ function add_case_template() { return false; } - let editor = ace.edit("editor_detail", + let editor = create_iris_code_editor("editor_detail", { autoScrollEditorIntoView: true, minLines: 30, }); - editor.setTheme("ace/theme/tomorrow"); - editor.session.setMode("ace/mode/json"); + editor.setMode("json"); editor.renderer.setShowGutter(true); editor.setOption("showLineNumbers", true); editor.setOption("showPrintMargin", false); @@ -170,13 +169,12 @@ function case_template_detail(ctempl_id) { return false; } - let editor = ace.edit("editor_detail", + let editor = create_iris_code_editor("editor_detail", { autoScrollEditorIntoView: true, minLines: 30, }); - editor.setTheme("ace/theme/tomorrow"); - editor.session.setMode("ace/mode/json"); + editor.setMode("json"); editor.renderer.setShowGutter(true); editor.setOption("showLineNumbers", true); editor.setOption("showPrintMargin", false); @@ -328,9 +326,12 @@ function upload_case_template() { function downloadCaseTemplateDefinition() { event.preventDefault(); - let editor = ace.edit("editor_detail"); + let editor = get_iris_editor("editor_detail"); + if (!editor) { + return; + } let data = editor.getSession().getValue(); let filename = "case_template.json"; download_file(filename, 'text/json' , data); -} \ No newline at end of file +} diff --git a/ui/src/pages/manage.modules.js b/ui/src/pages/manage.modules.js index 30caa4287..45132d16d 100644 --- a/ui/src/pages/manage.modules.js +++ b/ui/src/pages/manage.modules.js @@ -277,7 +277,7 @@ function update_param(module_id, param_name) { $('#submit_save_parameter').on("click", function () { var data = Object(); if ($('#editor_detail').length != 0) { - editor = ace.edit("editor_detail"); + let editor = get_iris_editor("editor_detail"); data['parameter_value'] = editor.getSession().getValue(); data['csrf_token'] = $('#csrf_token').val(); } else { diff --git a/ui/src/pages/milkdown_editor.js b/ui/src/pages/milkdown_editor.js index 03f3cd082..b7b6d3cb1 100644 --- a/ui/src/pages/milkdown_editor.js +++ b/ui/src/pages/milkdown_editor.js @@ -13,6 +13,8 @@ import { collab, collabServiceCtx } from '@milkdown/plugin-collab'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; +import { irisToMilkdown, milkdownToIris, uploadThroughIris } from './milkdown_shared.js'; +export { irisToMilkdown, milkdownToIris } from './milkdown_shared.js'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/frame.css'; import '../lib/milkdown_overrides.css'; @@ -294,110 +296,6 @@ function getCollabServerUrl(config) { return `${protocol}//${window.location.host}/collab`; } -// Run `fn` only on the parts of the markdown that are OUTSIDE fenced code blocks -// (``` ... ``` or ~~~ ... ~~~). Inline code spans rarely contain image syntax and are -// left alone for v1 simplicity. -function transformOutsideCode(md, fn) { - const fence = /(^|\n)([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g; - let out = ''; - let last = 0; - let m; - while ((m = fence.exec(md)) !== null) { - out += fn(md.slice(last, m.index)); - out += m[0]; // code block, untouched - last = m.index + m[0].length; - } - out += fn(md.slice(last)); - return out; -} - -// Parse the IRIS size token's WIDTH into a percent (integer) or null if not a percentage. -// Accepts widths like "100%", "50%", bare "*", "300" (px -> null, can't convert without natural size). -function widthTokenToPercent(w) { - if (!w) return null; - if (w === '*') return null; - if (w.endsWith('%')) { - const n = parseInt(w.slice(0, -1), 10); - return Number.isFinite(n) ? n : null; - } - return null; // px / bare number: no reliable %-of-page mapping in v1 -} - -// IRIS markdown -> markdown Crepe can parse (size encoded as block-image ratio in alt). -// Every image is normalised so its name survives in the caption slot; the width % (if any) -// becomes the block ratio (1.00 when no size, i.e. full width). Supports "double" and 'single' -// quoted titles. NOTE: URLs containing literal spaces or unbalanced parentheses are not -// handled (datastore URLs never contain those); such exotic links are left to Milkdown as-is. -export function irisToMilkdown(md) { - if (!md) return md; - const IMG = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)\s*(?:=([0-9]+%?|\*)(?:x([0-9]+%?|\*))?)?\s*(?:"([^"]*)"|'([^']*)')?\s*\)/g; - return transformOutsideCode(md, (seg) => seg.replace(IMG, - (full, alt, url, w /*width*/, _h /*height*/, dqTitle, sqTitle) => { - const title = dqTitle != null ? dqTitle : sqTitle; - const pct = widthTokenToPercent(w); - const ratio = pct === null ? 1 : Math.max(0.01, pct / 100); - const cap = (title || alt || '').replace(/"/g, ''); - return `![${ratio.toFixed(2)}](${url}${cap ? ` "${cap}"` : ''})`; - })); -} - -// markdown from Crepe.getMarkdown() -> IRIS canonical markdown. -// Block images come back as ![](url "caption"); restore the name and encode a width -// suffix ONLY when the image is not full width (ratio != 100%), so we never add a spurious -// size to images that were never resized. Inline / user-authored images are left untouched. -export function milkdownToIris(md) { - if (!md) return md; - const IMG = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)\s*(?:"([^"]*)")?\s*\)/g; - return transformOutsideCode(md, (seg) => seg.replace(IMG, - (full, alt, url, title) => { - // Only block images carry a purely-numeric ratio in alt. - if (!/^[0-9]*\.?[0-9]+$/.test(alt.trim())) { - return full; // inline / user-authored image: leave as-is - } - const ratio = parseFloat(alt); - if (!Number.isFinite(ratio)) return full; - const name = (title || '').replace(/[[\]]/g, ''); - const pct = Math.max(1, Math.round(ratio * 100)); - if (pct === 100) { - // Full width == IRIS default: store WITHOUT a size suffix. - return `![${name}](${url})`; - } - return `![${name}](${url} =${pct}%x*)`; - })); -} - -/* --------------------------------------------------------------------------- - * Image upload: route through IRIS's existing extension-preserving path - * (window globals defined by datastore.js / common.js on the notes page). - * ------------------------------------------------------------------------- */ -function uploadThroughIris(file) { - return new Promise((resolve, reject) => { - // upload_interactive_data() only invokes its callback on success (no .fail path), - // so guard against a hung promise with a timeout and single-settle latch. - let settled = false; - const finish = (fn, arg) => { if (!settled) { settled = true; clearTimeout(t); fn(arg); } }; - const t = setTimeout(() => finish(reject, new Error('image upload timed out')), 120000); - const reader = new FileReader(); - reader.onload = (e) => { - try { - const ext = window.get_extension_from_mime(file.type); - const filename = window.random_filename(25) + '.' + ext; - window.upload_interactive_data(e.target.result, filename, (data) => { - if (data && data.data && data.data.file_url) { - finish(resolve, data.data.file_url + window.case_param()); - } else { - finish(reject, new Error('upload did not return a file_url')); - } - }); - } catch (err) { - finish(reject, err); - } - }; - reader.onerror = () => finish(reject, new Error('failed to read pasted file')); - reader.readAsDataURL(file); - }); -} - /* --------------------------------------------------------------------------- */ let _crepe = null; let _onChange = null; diff --git a/ui/src/pages/milkdown_shared.js b/ui/src/pages/milkdown_shared.js new file mode 100644 index 000000000..81ae36b09 --- /dev/null +++ b/ui/src/pages/milkdown_shared.js @@ -0,0 +1,97 @@ +function transformOutsideCode(md, transform) { + const fence = /(^|\n)([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g; + let output = ''; + let last = 0; + let match; + while ((match = fence.exec(md)) !== null) { + output += transform(md.slice(last, match.index)); + output += match[0]; + last = match.index + match[0].length; + } + output += transform(md.slice(last)); + return output; +} + +function widthTokenToPercent(width) { + if (!width || width === '*' || !width.endsWith('%')) { + return null; + } + const value = parseInt(width.slice(0, -1), 10); + return Number.isFinite(value) ? value : null; +} + +export function irisToMilkdown(markdown) { + if (!markdown) { + return markdown; + } + const image = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)\s*(?:=([0-9]+%?|\*)(?:x([0-9]+%?|\*))?)?\s*(?:"([^"]*)"|'([^']*)')?\s*\)/g; + return transformOutsideCode(markdown, (segment) => segment.replace( + image, + (_full, alt, url, width, _height, doubleTitle, singleTitle) => { + const title = doubleTitle != null ? doubleTitle : singleTitle; + const percent = widthTokenToPercent(width); + const ratio = percent === null ? 1 : Math.max(0.01, percent / 100); + const caption = (title || alt || '').replace(/"/g, ''); + return `![${ratio.toFixed(2)}](${url}${caption ? ` "${caption}"` : ''})`; + } + )); +} + +export function milkdownToIris(markdown) { + if (!markdown) { + return markdown; + } + const image = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)\s*(?:"([^"]*)")?\s*\)/g; + return transformOutsideCode(markdown, (segment) => segment.replace( + image, + (full, alt, url, title) => { + if (!/^[0-9]*\.?[0-9]+$/.test(alt.trim())) { + return full; + } + const ratio = parseFloat(alt); + if (!Number.isFinite(ratio)) { + return full; + } + const name = (title || '').replace(/[[\]]/g, ''); + const percent = Math.max(1, Math.round(ratio * 100)); + return percent === 100 + ? `![${name}](${url})` + : `![${name}](${url} =${percent}%x*)`; + } + )); +} + +export function uploadThroughIris(file) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + callback(value); + } + }; + const timeout = setTimeout( + () => finish(reject, new Error('image upload timed out')), + 120000 + ); + const reader = new FileReader(); + reader.onload = (event) => { + try { + const extension = window.get_extension_from_mime(file.type); + const filename = `${window.random_filename(25)}.${extension}`; + window.upload_interactive_data(event.target.result, filename, (data) => { + if (data && data.data && data.data.file_url) { + finish(resolve, data.data.file_url + window.case_param()); + } else { + finish(reject, new Error('upload did not return a file_url')); + } + }); + } catch (error) { + finish(reject, error); + } + }; + reader.onerror = () => finish(reject, new Error('failed to read pasted file')); + reader.readAsDataURL(file); + }); +} diff --git a/ui/src/pages/unified_editor.js b/ui/src/pages/unified_editor.js new file mode 100644 index 000000000..13ea31638 --- /dev/null +++ b/ui/src/pages/unified_editor.js @@ -0,0 +1,533 @@ +import { Crepe } from '@milkdown/crepe'; +import { editorViewCtx, parserCtx, serializerCtx } from '@milkdown/kit/core'; +import { Slice } from '@milkdown/kit/prose/model'; +import { autocompletion } from '@codemirror/autocomplete'; +import { languages } from '@codemirror/language-data'; +import { Compartment, EditorState } from '@codemirror/state'; +import { EditorView, keymap, placeholder as placeholderExtension } from '@codemirror/view'; +import { basicSetup } from 'codemirror'; +import { irisToMilkdown, milkdownToIris, uploadThroughIris } from './milkdown_shared.js'; + +const instances = new WeakMap(); + +function resolveElement(anchor) { + const element = typeof anchor === 'string' ? document.getElementById(anchor) : anchor; + if (!element) { + throw new Error(`Editor anchor not found: ${anchor}`); + } + return element; +} + +function destroyPrevious(element) { + const previous = instances.get(element); + if (previous) { + previous.destroy(); + instances.delete(element); + } +} + +function editorTheme(element) { + const dark = element.dataset.theme === 'dark' + || document.documentElement.dataset.theme === 'dark' + || document.body.classList.contains('dark-mode'); + return EditorView.theme({ + '&': { + backgroundColor: dark ? '#1f2937' : '#fff', + color: dark ? '#e5e7eb' : '#1f2937', + border: `1px solid ${dark ? '#4b5563' : '#ced4da'}`, + borderRadius: '4px', + fontSize: '0.875rem', + }, + '&.cm-focused': { + outline: 'none', + borderColor: dark ? '#60a5fa' : '#80bdff', + boxShadow: dark + ? '0 0 0 0.2rem rgb(96 165 250 / 20%)' + : '0 0 0 0.2rem rgb(0 123 255 / 20%)', + }, + '.cm-content': { + caretColor: dark ? '#f9fafb' : '#111827', + minHeight: '3rem', + padding: '0.5rem', + }, + '.cm-gutters': { + backgroundColor: dark ? '#111827' : '#f8f9fa', + color: dark ? '#9ca3af' : '#6c757d', + borderRight: `1px solid ${dark ? '#374151' : '#dee2e6'}`, + }, + '.cm-activeLine, .cm-activeLineGutter': { + backgroundColor: dark ? '#263244' : '#f3f7fb', + }, + '.cm-selectionBackground, ::selection': { + backgroundColor: dark ? '#374f78 !important' : '#cfe8ff !important', + }, + }); +} + +function normalizeKey(key) { + return String(key || '') + .replace(/Command/gi, 'Meta') + .replace(/Cmd/gi, 'Meta') + .replace(/Control/gi, 'Ctrl') + .replace(/Ctrl-([A-Z])/g, (_match, letter) => `Ctrl-${letter.toLowerCase()}`) + .replace(/Meta-([A-Z])/g, (_match, letter) => `Meta-${letter.toLowerCase()}`); +} + +function findLanguage(mode) { + const name = String(mode || '').split('/').pop().toLowerCase(); + return languages.find((description) => { + if (description.name.toLowerCase() === name) { + return true; + } + return Array.isArray(description.alias) + && description.alias.some((alias) => alias.toLowerCase() === name); + }) || null; +} + +function completionSourceFromLegacy(completer, editor) { + return async (context) => { + const token = context.matchBefore(/[^\s,{}[\]()]+/); + if (!token && !context.explicit) { + return null; + } + const prefix = token ? token.text : ''; + const values = await new Promise((resolve) => { + completer.getCompletions(editor, editor, null, prefix, (error, items) => { + resolve(error || !Array.isArray(items) ? [] : items); + }); + }); + return { + from: token ? token.from : context.pos, + options: values.map((item) => ({ + label: item.caption || item.value || '', + apply: item.value || item.caption || '', + detail: item.meta || '', + boost: Number(item.score) || 0, + })), + }; + }; +} + +class CodeEditor { + constructor(anchor, options = {}) { + this.element = resolveElement(anchor); + destroyPrevious(this.element); + this.element.classList.add('iris-code-editor'); + this.changeHandlers = []; + this.commandBindings = []; + this.languageCompartment = new Compartment(); + this.editableCompartment = new Compartment(); + this.placeholderCompartment = new Compartment(); + this.completionCompartment = new Compartment(); + this.commandCompartment = new Compartment(); + this.session = this; + this.renderer = { + setShowGutter: (show) => this.setOption('showLineNumbers', show), + setScrollMargin: () => {}, + }; + + const initialValue = this.element.textContent || ''; + this.element.textContent = ''; + this.view = new EditorView({ + parent: this.element, + state: EditorState.create({ + doc: initialValue, + extensions: [ + basicSetup, + editorTheme(this.element), + EditorView.lineWrapping, + this.languageCompartment.of([]), + this.editableCompartment.of(EditorView.editable.of(true)), + this.placeholderCompartment.of([]), + this.completionCompartment.of([]), + this.commandCompartment.of([]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + this.changeHandlers.forEach((handler) => handler()); + } + }), + ], + }), + }); + instances.set(this.element, this); + Object.entries(options || {}).forEach(([name, value]) => this.setOption(name, value)); + } + + getValue() { + return this.view.state.doc.toString(); + } + + setValue(value) { + const next = value == null ? '' : String(value); + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert: next }, + selection: { anchor: 0 }, + }); + return this; + } + + getSession() { + return this; + } + + on(event, handler) { + if (event === 'change' && typeof handler === 'function') { + this.changeHandlers.push(handler); + } + return this; + } + + off(event) { + if (event === 'change') { + this.changeHandlers = []; + } + return this; + } + + async setMode(mode) { + const description = findLanguage(mode); + const support = description ? await description.load() : []; + if (this.view) { + this.view.dispatch({ effects: this.languageCompartment.reconfigure(support) }); + } + return this; + } + + setReadOnly(readOnly) { + this.view.dispatch({ + effects: this.editableCompartment.reconfigure(EditorView.editable.of(!readOnly)), + }); + return this; + } + + setUseWrapMode() { + return this; + } + + setTheme() { + return this; + } + + setShowPrintMargin() { + return this; + } + + setCompletions(completers) { + const list = Array.isArray(completers) ? completers : []; + const override = list + .filter((item) => item && typeof item.getCompletions === 'function') + .map((item) => completionSourceFromLegacy(item, this)); + this.view.dispatch({ + effects: this.completionCompartment.reconfigure( + override.length ? autocompletion({ override }) : [] + ), + }); + return this; + } + + setOption(name, value) { + switch (name) { + case 'minLines': + this.element.style.minHeight = `${Math.max(2, Number(value) || 2) * 1.45 + 1}rem`; + this.view.dom.style.minHeight = this.element.style.minHeight; + break; + case 'maxLines': + if (Number.isFinite(Number(value))) { + this.element.style.maxHeight = `${Number(value) * 1.45 + 1}rem`; + this.element.style.overflow = 'auto'; + this.view.dom.style.maxHeight = this.element.style.maxHeight; + } + break; + case 'placeholder': + this.view.dispatch({ + effects: this.placeholderCompartment.reconfigure( + value ? placeholderExtension(String(value)) : [] + ), + }); + break; + case 'readOnly': + this.setReadOnly(Boolean(value)); + break; + case 'showLineNumbers': + this.view.dom.querySelectorAll('.cm-gutters').forEach((gutter) => { + gutter.style.display = value ? '' : 'none'; + }); + break; + case 'enableBasicAutocompletion': + if (Array.isArray(value)) { + this.setCompletions(value); + } + break; + default: + break; + } + return this; + } + + setOptions(options) { + Object.entries(options || {}).forEach(([name, value]) => this.setOption(name, value)); + return this; + } + + addCommand(command) { + if (!command || typeof command.exec !== 'function') { + return this; + } + const binding = command.bindKey || {}; + const platformKey = /Mac|iPhone|iPad/.test(navigator.platform) + ? binding.mac || binding.win + : binding.win || binding.mac; + if (!platformKey) { + return this; + } + this.commandBindings.push({ + key: normalizeKey(platformKey), + run: () => { + command.exec(this); + return true; + }, + }); + this.view.dispatch({ + effects: this.commandCompartment.reconfigure(keymap.of(this.commandBindings)), + }); + return this; + } + + get commands() { + return { addCommand: (command) => this.addCommand(command) }; + } + + insertSnippet(snippet) { + const selection = this.view.state.selection.main; + const selected = this.view.state.sliceDoc(selection.from, selection.to); + const insert = String(snippet || '') + .replace(/\$\{1:\$SELECTION\}/g, selected) + .replace(/\$SELECTION/g, selected); + this.view.dispatch({ + changes: { from: selection.from, to: selection.to, insert }, + selection: { anchor: selection.from + insert.length }, + }); + return this; + } + + focus() { + this.view.focus(); + return this; + } + + resize() { + this.view.requestMeasure(); + return this; + } + + destroy() { + if (this.view) { + this.view.destroy(); + this.view = null; + } + } +} + +class MilkdownEditor { + constructor(anchor, options = {}) { + this.element = resolveElement(anchor); + destroyPrevious(this.element); + this.element.classList.add('iris-markdown-editor'); + this.element.removeAttribute('contenteditable'); + this.element.removeAttribute('spellcheck'); + this.value = this.element.textContent || ''; + this.changeHandlers = []; + this.readOnly = Boolean(options.readOnly); + this.crepe = null; + this.element.textContent = ''; + instances.set(this.element, this); + this.ready = this.initialize(); + Object.entries(options || {}).forEach(([name, value]) => this.setOption(name, value)); + } + + async initialize() { + const initialValue = this.value; + const crepe = new Crepe({ + root: this.element, + defaultValue: irisToMilkdown(this.value), + featureConfigs: { + [Crepe.Feature.ImageBlock]: { + onUpload: uploadThroughIris, + }, + }, + }); + await crepe.create(); + this.crepe = crepe; + this.applyReadOnly(); + this.applyMinimumHeight(); + if (this.value !== initialValue) { + this.setValue(this.value); + } + crepe.on((listener) => { + listener.markdownUpdated((_ctx, markdown) => { + this.value = milkdownToIris(markdown || ''); + this.changeHandlers.forEach((handler) => handler()); + }); + }); + return this; + } + + readMarkdown() { + if (!this.crepe) { + return this.value; + } + let markdown = this.value; + this.crepe.editor.action((ctx) => { + const serializer = ctx.get(serializerCtx); + const view = ctx.get(editorViewCtx); + markdown = milkdownToIris(serializer(view.state.doc)); + }); + this.value = markdown; + return markdown; + } + + getValue() { + return this.readMarkdown(); + } + + setValue(value) { + this.value = value == null ? '' : String(value); + if (!this.crepe) { + return this; + } + this.crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const parser = ctx.get(parserCtx); + const doc = parser(irisToMilkdown(this.value)); + if (!doc) { + return; + } + const transaction = view.state.tr.replace( + 0, + view.state.doc.content.size, + new Slice(doc.content, 0, 0) + ); + transaction.setMeta('addToHistory', false); + view.dispatch(transaction); + }); + return this; + } + + setOption(name, value) { + if (name === 'minLines') { + this.minimumHeight = `${Math.max(2, Number(value) || 2) * 1.45 + 1}rem`; + this.applyMinimumHeight(); + } else if (name === 'readOnly') { + this.setReadOnly(Boolean(value)); + } + return this; + } + + applyMinimumHeight() { + if (!this.minimumHeight) { + return; + } + this.element.style.minHeight = this.minimumHeight; + const editable = this.element.querySelector('.ProseMirror'); + if (editable) { + editable.style.minHeight = this.minimumHeight; + } + } + + setOptions(options) { + Object.entries(options || {}).forEach(([name, value]) => this.setOption(name, value)); + return this; + } + + setReadOnly(readOnly) { + this.readOnly = Boolean(readOnly); + this.applyReadOnly(); + return this; + } + + applyReadOnly() { + if (!this.crepe) { + return; + } + this.crepe.editor.action((ctx) => { + ctx.get(editorViewCtx).setProps({ editable: () => !this.readOnly }); + }); + } + + on(event, handler) { + if (event === 'change' && typeof handler === 'function') { + this.changeHandlers.push(handler); + } + return this; + } + + off(event) { + if (event === 'change') { + this.changeHandlers = []; + } + return this; + } + + insertSnippet(snippet) { + if (!this.crepe) { + this.value += String(snippet || '').replace(/\$\{1:\$SELECTION\}/g, ''); + return this; + } + this.crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const { from, to } = view.state.selection; + const selected = view.state.doc.textBetween(from, to, '\n'); + const insert = String(snippet || '') + .replace(/\$\{1:\$SELECTION\}/g, selected) + .replace(/\$SELECTION/g, selected); + view.dispatch(view.state.tr.insertText(insert, from, to)); + }); + return this; + } + + focus() { + if (this.crepe) { + this.crepe.editor.action((ctx) => ctx.get(editorViewCtx).focus()); + } else { + this.ready.then(() => this.focus()); + } + return this; + } + + resize() { + return this; + } + + async destroy() { + if (this.crepe) { + try { + await this.crepe.destroy(); + } catch { + // The containing modal may already have been removed. + } + this.crepe = null; + } + } +} + +function createCodeEditor(anchor, options = {}) { + return new CodeEditor(anchor, options); +} + +function createMarkdownEditor(anchor, options = {}) { + return new MilkdownEditor(anchor, options); +} + +function getEditor(anchor) { + return instances.get(resolveElement(anchor)) || null; +} + +window.IrisUnifiedEditor = { + createCodeEditor, + createMarkdownEditor, + getEditor, +}; +window.create_iris_code_editor = createCodeEditor; +window.create_iris_markdown_editor = createMarkdownEditor; +window.get_iris_editor = getEditor; +window.dispatchEvent(new CustomEvent('iris-unified-editor-ready')); diff --git a/ui/test/milkdown_shared.test.js b/ui/test/milkdown_shared.test.js new file mode 100644 index 000000000..a4d7c98ef --- /dev/null +++ b/ui/test/milkdown_shared.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { irisToMilkdown, milkdownToIris } from '../src/pages/milkdown_shared.js'; + +test('round-trips IRIS image dimensions through Milkdown', () => { + const iris = 'Before\n\n![Screenshot](/datastore/file.png =45%x*)\n\nAfter'; + const milkdown = irisToMilkdown(iris); + + assert.equal(milkdown, 'Before\n\n![0.45](/datastore/file.png "Screenshot")\n\nAfter'); + assert.equal(milkdownToIris(milkdown), iris); +}); + +test('does not add a size suffix to full-width images', () => { + const iris = '![Evidence](/datastore/evidence.png)'; + + assert.equal(milkdownToIris(irisToMilkdown(iris)), iris); +}); + +test('leaves image-like syntax inside fenced code unchanged', () => { + const markdown = [ + '```markdown', + '![Example](/file.png =25%x*)', + '```', + '', + '![Real](/real.png =50%x*)', + ].join('\n'); + const converted = irisToMilkdown(markdown); + + assert.match(converted, /!\[Example\]\(\/file\.png =25%x\*\)/); + assert.match(converted, /!\[0\.50\]\(\/real\.png "Real"\)/); + assert.equal(milkdownToIris(converted), markdown); +}); + +test('leaves ordinary user-authored images unchanged on serialization', () => { + const markdown = '![an ordinary alt](/image.png "title")'; + + assert.equal(milkdownToIris(markdown), markdown); +}); diff --git a/ui/vite.config.js b/ui/vite.config.js index 8190a52e2..453e570a8 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -105,10 +105,6 @@ export default defineConfig(({ mode }) => { rename: 'charts.js', }, // Plugins - { - src: 'node_modules/ace-builds/src-noconflict/', - dest: 'assets/js/plugin/ace/', - }, { src: 'node_modules/bootstrap-slider/dist/bootstrap-slider.min.js', dest: 'assets/js/plugin/bootstrap-slider/', From 1e4d8157f0d9b72e62f5ad41f405ce69efdade6d Mon Sep 17 00:00:00 2001 From: Marc Schlaeppi Date: Wed, 15 Jul 2026 07:22:17 +0200 Subject: [PATCH 02/20] Fix password MFA verification feedback --- .../blueprints/pages/login/login_routes.py | 50 +++++++----- .../pages/login/templates/_auth_messages.html | 17 ++++ .../pages/login/templates/login.html | 4 +- .../pages/login/templates/mfa_setup.html | 3 +- .../pages/login/templates/mfa_verify.html | 3 +- source/app/business/auth.py | 9 ++- source/app/forms.py | 5 ++ tests/tests_auth_mfa.py | 80 +++++++++++++++++++ 8 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 source/app/blueprints/pages/login/templates/_auth_messages.html diff --git a/source/app/blueprints/pages/login/login_routes.py b/source/app/blueprints/pages/login/login_routes.py index 51353a6a1..9b85754a6 100644 --- a/source/app/blueprints/pages/login/login_routes.py +++ b/source/app/blueprints/pages/login/login_routes.py @@ -46,7 +46,7 @@ from app.datamgmt.manage.manage_users_db import create_user from app.datamgmt.manage.manage_users_db import update_user_groups from app.datamgmt.manage.manage_users_db import get_user -from app.forms import LoginForm, MFASetupForm +from app.forms import LoginForm, MFASetupForm, MFAVerifyForm from app.blueprints.iris_user import iris_current_user from app.iris_engine.utils.tracker import track_activity from app.datamgmt.manage.manage_groups_db import get_groups_list @@ -154,8 +154,7 @@ def login(): form = LoginForm(request.form) - # check if both http method is POST and form is valid on submit - if not form.is_submitted() and not form.validate(): + if not form.validate_on_submit(): return _render_template_login(form, None) # assign form data to variables @@ -406,20 +405,27 @@ def _clear_pre_mfa_state(preserve_lockout=False): session.pop("pending_mfa_secret", None) if not preserve_lockout: session.pop("mfa_fail_count", None) + session.pop("mfa_fail_user_id", None) session.pop("mfa_lockout_until", None) -def _mfa_is_locked_out(): +def _mfa_is_locked_out(user): + if session.get("mfa_fail_user_id") != user.id: + return False locked_until = session.get("mfa_lockout_until") if locked_until and locked_until > time.time(): return True if locked_until and locked_until <= time.time(): session.pop("mfa_lockout_until", None) + session.pop("mfa_fail_user_id", None) session["mfa_fail_count"] = 0 return False def _register_mfa_failure(user, reason): + if session.get("mfa_fail_user_id") != user.id: + session["mfa_fail_count"] = 0 + session["mfa_fail_user_id"] = user.id session["mfa_fail_count"] = session.get("mfa_fail_count", 0) + 1 track_activity( f"Failed MFA {reason} for user {user.user} " @@ -433,6 +439,8 @@ def _register_mfa_failure(user, reason): # the lockout timestamp so a fresh /login cannot wipe it. _clear_pre_mfa_state(preserve_lockout=True) session.pop("username", None) + return True + return False @app.route("/auth/mfa-setup", methods=["GET", "POST"]) @@ -449,13 +457,13 @@ def mfa_setup(): if user.mfa_setup_complete and user.mfa_secrets: return redirect(url_for("mfa_verify")) - if _mfa_is_locked_out(): + if _mfa_is_locked_out(user): flash("Too many attempts. Please try again later.", "danger") return redirect(url_for("login.login")) form = MFASetupForm() - if form.submit() and form.validate(): + if form.validate_on_submit(): token = form.token.data user_password = form.user_password.data @@ -469,7 +477,7 @@ def mfa_setup(): totp = pyotp.TOTP(mfa_secret) - if totp.verify(token): + if totp.verify(str(token).strip(), valid_window=1): has_valid_password = False if is_authentication_ldap() is True: if validate_ldap_login( @@ -483,7 +491,10 @@ def mfa_setup(): has_valid_password = True if not has_valid_password: - _register_mfa_failure(user, "setup (invalid password)") + locked_out = _register_mfa_failure(user, "setup (invalid password)") + if locked_out: + flash("Too many attempts. Please try again later.", "danger") + return redirect(url_for("login.login")) flash("Invalid password. Please try again.", "danger") return render_template("mfa_setup.html", form=form) @@ -501,7 +512,10 @@ def mfa_setup(): session["mfa_verified_for_user_id"] = user.id _clear_pre_mfa_state() return wrap_login_user(user) - _register_mfa_failure(user, "setup (invalid token)") + locked_out = _register_mfa_failure(user, "setup (invalid token)") + if locked_out: + flash("Too many attempts. Please try again later.", "danger") + return redirect(url_for("login.login")) flash("Invalid token or password. Please try again.", "danger") # Generate a fresh secret on every GET and stash it in the session. The @@ -536,21 +550,16 @@ def mfa_verify(): ) return redirect(url_for("mfa_setup")) - if _mfa_is_locked_out(): + if _mfa_is_locked_out(user): flash("Too many attempts. Please try again later.", "danger") return redirect(url_for("login.login")) - form = MFASetupForm() - form.user_password.data = "not required for verification" + form = MFAVerifyForm() - if form.submit() and form.validate(): + if form.validate_on_submit(): token = form.token.data - if not token: - flash("Token is required.", "danger") - return render_template("mfa_verify.html", form=form) - totp = pyotp.TOTP(user.mfa_secrets) - if totp.verify(token): + if totp.verify(str(token).strip(), valid_window=1): track_activity( f"MFA verification successful for user {user.user}", ctx_less=True, display_in_ui=False, @@ -561,7 +570,10 @@ def mfa_verify(): session["mfa_verified_for_user_id"] = user.id _clear_pre_mfa_state() return wrap_login_user(user) - _register_mfa_failure(user, "verification (invalid token)") + locked_out = _register_mfa_failure(user, "verification (invalid token)") + if locked_out: + flash("Too many attempts. Please try again later.", "danger") + return redirect(url_for("login.login")) flash("Invalid token. Please try again.", "danger") return render_template("mfa_verify.html", form=form) diff --git a/source/app/blueprints/pages/login/templates/_auth_messages.html b/source/app/blueprints/pages/login/templates/_auth_messages.html new file mode 100644 index 000000000..8c895dd0e --- /dev/null +++ b/source/app/blueprints/pages/login/templates/_auth_messages.html @@ -0,0 +1,17 @@ +{% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} +{% endwith %} + +{% if form and form.errors %} + +{% endif %} diff --git a/source/app/blueprints/pages/login/templates/login.html b/source/app/blueprints/pages/login/templates/login.html index 586a669e2..27d8c873a 100644 --- a/source/app/blueprints/pages/login/templates/login.html +++ b/source/app/blueprints/pages/login/templates/login.html @@ -22,6 +22,8 @@

{{ organisation_name }}