From 698c3914edb8f965994f322c9fd28d686380e8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Thu, 25 Jun 2026 12:53:33 +0200 Subject: [PATCH 01/10] First step of adding @mentions to markdown editor --- .../public/assets/js/pos-markdown.js | 159 ++++++++++++++++++ .../public/assets/style/pos-markdown.css | 33 ++++ 2 files changed, 192 insertions(+) diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index bfa6ae0..5408115 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -35,6 +35,12 @@ window.pos.modules.markdown = function(settings){ // easymde instance (object) module.settings.easyMde = null; + // async function(query) => [{ id, name }] — provided by consumer, enables @mentions (optional) + module.settings.mentionSearch = settings.mentionSearch || async function(query){ return Promise.resolve([{"name": "john doe", "c__names": "john doe johndoe@gmail.com","id": "11"},{"name": "łukasz krysiewicz", "c__names": "łukasz krysiewicz zx@secondgate.com","id": "15"},{"name": "jj bragg", "c__names": "jj bragg jjbragg@platformos.com","id": "18"}]); }; + // mention popup DOM node + module.settings.mentionPopup = null; + // active @mention state { query, line, atCh } or null + module.settings.mentionState = null; @@ -101,9 +107,161 @@ window.pos.modules.markdown = function(settings){ }, true); // capture phase — fires before codemirror's listener on the inner textarea pos.modules.debug(module.settings.debug, module.settings.id, 'EasyMDE instance created', module.settings.easyMde); + + module.startAtMentions(); + }; + + + + // purpose: sets up @mention detection and popup on the CodeMirror instance + // ------------------------------------------------------------------------ + module.startAtMentions = () => { + if (!module.settings.mentionSearch) return; + + const popup = document.createElement('ul'); + popup.className = 'pos-mention-popup'; + document.body.appendChild(popup); + module.settings.mentionPopup = popup; + + const cm = module.settings.easyMde.codemirror; + + cm.on('change', async () => { + const cursor = cm.getCursor(); + const textBefore = cm.getLine(cursor.line).slice(0, cursor.ch); + const atIdx = textBefore.lastIndexOf('@'); + + if (atIdx === -1) { module.hideMentionPopup(); return; } + + const charBefore = atIdx > 0 ? textBefore[atIdx - 1] : ' '; + const query = textBefore.slice(atIdx + 1); + + if (!/\s/.test(charBefore) && atIdx > 0) { module.hideMentionPopup(); return; } + if (query.length < 1 || /\s/.test(query)) { module.hideMentionPopup(); return; } + + module.settings.mentionState = { query, line: cursor.line, atCh: atIdx }; + const results = await module.settings.mentionSearch(query); + module.renderMentionPopup(results); + }); + + document.addEventListener('click', e => { + if (!popup.contains(e.target)) module.hideMentionPopup(); + }); + + cm.addKeyMap({ + 'Esc': () => module.hideMentionPopup(), + 'Up': () => module.moveMentionSelection(-1), + 'Down': () => module.moveMentionSelection(1), + 'Enter': () => { + const active = module.settings.mentionPopup?.querySelector('li.pos-mention-active'); + if (active) { active.dispatchEvent(new MouseEvent('mousedown')); return true; } + } + }); + + module.reapplyMentionMarks(); }; + // purpose: renders mention results in the popup, positioned at the cursor + // ------------------------------------------------------------------------ + module.renderMentionPopup = (results) => { + const popup = module.settings.mentionPopup; + const cm = module.settings.easyMde.codemirror; + popup.innerHTML = ''; + + if (!results?.length) { module.hideMentionPopup(); return; } + + results.forEach(person => { + const li = document.createElement('li'); + li.textContent = person.name; + li.addEventListener('mouseenter', () => { + popup.querySelectorAll('li').forEach(el => el.classList.remove('pos-mention-active')); + li.classList.add('pos-mention-active'); + }); + li.addEventListener('mousedown', e => { e.preventDefault(); module.insertMention(person); }); + popup.appendChild(li); + }); + + const coords = cm.cursorCoords(true, 'window'); + popup.style.left = coords.left + 'px'; + popup.style.top = (coords.bottom + 4) + 'px'; + popup.style.display = 'block'; + }; + + + // purpose: moves keyboard selection up/down within the mention popup + // ------------------------------------------------------------------------ + module.moveMentionSelection = (direction) => { + const popup = module.settings.mentionPopup; + if (!popup || popup.style.display === 'none') return; + const items = Array.from(popup.querySelectorAll('li')); + if (!items.length) return; + const current = items.findIndex(li => li.classList.contains('pos-mention-active')); + const next = Math.max(0, Math.min(items.length - 1, current + direction)); + items.forEach(li => li.classList.remove('pos-mention-active')); + items[next].classList.add('pos-mention-active'); + }; + + + // purpose: hides the mention popup and clears state + // ------------------------------------------------------------------------ + module.hideMentionPopup = () => { + if (module.settings.mentionPopup) module.settings.mentionPopup.style.display = 'none'; + module.settings.mentionState = null; + }; + + + // purpose: collapses "[" and "](id)" in a single mention so only "@Name" is visible + // ------------------------------------------------------------------------ + module.applyMentionMark = (line, atCh, name, id) => { + const cm = module.settings.easyMde.codemirror; + // @[Name](id) — collapse "[" (1 char) so "@Name" is visible + cm.markText( + { line, ch: atCh + 1 }, + { line, ch: atCh + 2 }, + { collapsed: true, atomic: true } + ); + // collapse "](id)" (id.length + 3 chars) so nothing after the name is visible + cm.markText( + { line, ch: atCh + 2 + name.length }, + { line, ch: atCh + 2 + name.length + id.length + 3 }, + { collapsed: true, atomic: true } + ); + }; + + + // purpose: re-applies mention marks on existing content (init or programmatic value set) + // ------------------------------------------------------------------------ + module.reapplyMentionMarks = () => { + if (!module.settings.mentionSearch) return; + const cm = module.settings.easyMde.codemirror; + const regex = /@\[([^\]]+)\]\(([^)]+)\)/g; + for (let line = 0; line < cm.lineCount(); line++) { + regex.lastIndex = 0; + let match; + while ((match = regex.exec(cm.getLine(line))) !== null) { + module.applyMentionMark(line, match.index, match[1], match[2]); + } + } + }; + + + // purpose: replaces the @query text with the selected mention + // ------------------------------------------------------------------------ + module.insertMention = (person) => { + const cm = module.settings.easyMde.codemirror; + const s = module.settings.mentionState; + if (!s) return; + const cursor = cm.getCursor(); + cm.replaceRange( + `@[${person.name}](${person.id}) `, + { line: s.line, ch: s.atCh }, + { line: cursor.line, ch: cursor.ch } + ); + module.applyMentionMark(s.line, s.atCh, person.name, person.id); + module.hideMentionPopup(); + cm.focus(); + }; + // purpose: uploads images in the editor // ------------------------------------------------------------------------ @@ -191,6 +349,7 @@ window.pos.modules.markdown = function(settings){ module.value = (value) => { if(value){ module.settings.easyMde.value(value); + module.reapplyMentionMarks(); pos.modules.debug(module.settings.debug, module.settings.id, 'Changed editor content', value); // dispatch custom event diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css index 703f572..ef5a9c8 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css @@ -71,6 +71,39 @@ } + /* @mention popup + ============================================================================ */ + .pos-mention-popup { + position: fixed; + z-index: 9999; + display: none; + list-style: none; + margin: 0; + padding: var(--pos-spacing-1, 4px) 0; + min-width: 200px; + max-height: 220px; + overflow-y: auto; + background: var(--pos-color-card-background, #fff); + border: 1px solid var(--pos-color-input-frame, #ccc); + border-radius: var(--pos-radius-input, 4px); + box-shadow: 0 4px 16px rgba(0, 0, 0, .12); + } + + .pos-mention-popup li { + padding: 7px 14px; + cursor: pointer; + font-size: .875rem; + color: var(--pos-color-input-text, #1a1a1a); + line-height: 1.4; + } + + .pos-mention-popup li:hover, + .pos-mention-popup li.pos-mention-active { + background: var(--pos-color-highlight-background, #f0f4ff); + color: var(--pos-color-link, inherit); + } + + /* content ============================================================================ */ .EasyMDEContainer .CodeMirror-code [data-img-src]::after { From bb335bd311cd40720d2ee81715a04a722aed1b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Mon, 29 Jun 2026 12:36:07 +0200 Subject: [PATCH 02/10] Fix for tab trap --- .../public/assets/js/pos-markdown.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 5408115..582c522 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -31,7 +31,7 @@ window.pos.modules.markdown = function(settings){ // class name that hides error on the list (string) module.settings.errorDisabledClass = 'pos-markdown-error-disabled'; // debug mode enabled (bool) - module.settings.debug = typeof settings.debug === 'boolean' ? settings.debug : false; + module.settings.debug = typeof settings.debug === 'boolean' ? settings.debug : true; // easymde instance (object) module.settings.easyMde = null; @@ -88,16 +88,19 @@ window.pos.modules.markdown = function(settings){ // accessibility: escape releases the Tab trap so keyboard users can navigate out // pressing Esc sets a flag; the capture-phase listener on the wrapper intercepts the next tab before codemirror can call preventDefault, letting the browser move focus naturally; any other key cancels the released state const cmWrapper = module.settings.easyMde.codemirror.getWrapperElement(); - + module.settings.easyMde.codemirror.addKeyMap({ 'Esc': function(){ module.settings.easyMde.codemirror.state.tabTrapReleased = true; + }, + 'Escape': function(){ + module.settings.easyMde.codemirror.state.tabTrapReleased = true; } }); - cmWrapper.addEventListener('keydown', function(event) { - if (module.settings.easyMde.codemirror.state.tabTrapReleased) { - if(event.key === 'Tab') { + cmWrapper.addEventListener('keydown', function(event){ + if(module.settings.easyMde.codemirror.state.tabTrapReleased){ + if(event.key === 'Tab'){ module.settings.easyMde.codemirror.state.tabTrapReleased = false; event.stopPropagation(); // codemirror never sees it → never calls preventDefault → browser navigates } else if(event.key !== 'Escape') { @@ -148,7 +151,13 @@ window.pos.modules.markdown = function(settings){ }); cm.addKeyMap({ - 'Esc': () => module.hideMentionPopup(), + 'Esc': () => { + if (module.settings.mentionPopup?.style.display === 'block') { + module.hideMentionPopup(); + } else { + return cm.constructor.Pass; // fall through to tab-trap keymap + } + }, 'Up': () => module.moveMentionSelection(-1), 'Down': () => module.moveMentionSelection(1), 'Enter': () => { From bb8bcd636489ce91f1e4aacad516fd750b020717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Mon, 29 Jun 2026 17:10:09 +0200 Subject: [PATCH 03/10] Working keyboard navigation --- .../views/pages/api/users/search.json.liquid | 12 ++ .../public/assets/js/pos-markdown.js | 104 +++++++++--------- .../public/assets/js/pos-popover.js | 33 +++++- .../public/assets/style/pos-markdown.css | 27 +---- .../views/partials/forms/markdown.liquid | 33 +++--- .../views/partials/style-guide/forms.liquid | 6 +- .../platformos-tools.log | 6 + 7 files changed, 122 insertions(+), 99 deletions(-) create mode 100644 pos-module-common-styling/app/views/pages/api/users/search.json.liquid create mode 100644 pos-module-common-styling/platformos-tools.log diff --git a/pos-module-common-styling/app/views/pages/api/users/search.json.liquid b/pos-module-common-styling/app/views/pages/api/users/search.json.liquid new file mode 100644 index 0000000..dcb52e8 --- /dev/null +++ b/pos-module-common-styling/app/views/pages/api/users/search.json.liquid @@ -0,0 +1,12 @@ +[ + { + "id": "1", + "name": "John Kowalski", + "avatar": "" + }, + { + "id": "2", + "name": "Jane Doe", + "avatar": "" + } +] \ No newline at end of file diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 582c522..2957494 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -30,15 +30,21 @@ window.pos.modules.markdown = function(settings){ module.settings.errorsContainer = module.settings.container.querySelector('.pos-form-errors'); // class name that hides error on the list (string) module.settings.errorDisabledClass = 'pos-markdown-error-disabled'; + + // @mention settings (object) + module.settings.mention = {}; + // instance of the popover with @mentions (object) + module.settings.mention.popover = pos.modules.active[`${module.settings.id}-mention-popover`]; + // list with @mention results (dom node) + module.settings.mention.results = module.settings.container.querySelector(`#${module.settings.id}-mention-popover`); + // debug mode enabled (bool) - module.settings.debug = typeof settings.debug === 'boolean' ? settings.debug : true; + module.settings.debug = typeof settings.debug === 'boolean' ? settings.debug : false; // easymde instance (object) module.settings.easyMde = null; // async function(query) => [{ id, name }] — provided by consumer, enables @mentions (optional) module.settings.mentionSearch = settings.mentionSearch || async function(query){ return Promise.resolve([{"name": "john doe", "c__names": "john doe johndoe@gmail.com","id": "11"},{"name": "łukasz krysiewicz", "c__names": "łukasz krysiewicz zx@secondgate.com","id": "15"},{"name": "jj bragg", "c__names": "jj bragg jjbragg@platformos.com","id": "18"}]); }; - // mention popup DOM node - module.settings.mentionPopup = null; // active @mention state { query, line, atCh } or null module.settings.mentionState = null; @@ -85,25 +91,25 @@ window.pos.modules.markdown = function(settings){ previewClass: ['pos-prose', 'editor-preview'] }); - // accessibility: escape releases the Tab trap so keyboard users can navigate out + // purpose: escape releases the TAB trap so keyboard users can navigate out // pressing Esc sets a flag; the capture-phase listener on the wrapper intercepts the next tab before codemirror can call preventDefault, letting the browser move focus naturally; any other key cancels the released state - const cmWrapper = module.settings.easyMde.codemirror.getWrapperElement(); - module.settings.easyMde.codemirror.addKeyMap({ 'Esc': function(){ - module.settings.easyMde.codemirror.state.tabTrapReleased = true; - }, - 'Escape': function(){ + pos.modules.debug(module.settings.debug, module.settings.id, 'Escape pressed, releasing tab trap', module.settings.easyMde); module.settings.easyMde.codemirror.state.tabTrapReleased = true; } }); + const cmWrapper = module.settings.easyMde.codemirror.getWrapperElement(); + cmWrapper.addEventListener('keydown', function(event){ if(module.settings.easyMde.codemirror.state.tabTrapReleased){ if(event.key === 'Tab'){ + pos.modules.debug(module.settings.debug, module.settings.id, 'Tab trap was released, navigating focus outside of the editor', module.settings.easyMde); module.settings.easyMde.codemirror.state.tabTrapReleased = false; event.stopPropagation(); // codemirror never sees it → never calls preventDefault → browser navigates - } else if(event.key !== 'Escape') { + } else if(event.key !== 'Escape'){ + pos.modules.debug(module.settings.debug, module.settings.id, 'Re-engaging tab trap as the user resumed editing', module.settings.easyMde); module.settings.easyMde.codemirror.state.tabTrapReleased = false; // user resumed editing, re-engage tab trap } } @@ -116,21 +122,14 @@ window.pos.modules.markdown = function(settings){ - // purpose: sets up @mention detection and popup on the CodeMirror instance + // purpose: sets up @mention detection and popup // ------------------------------------------------------------------------ module.startAtMentions = () => { - if (!module.settings.mentionSearch) return; - - const popup = document.createElement('ul'); - popup.className = 'pos-mention-popup'; - document.body.appendChild(popup); - module.settings.mentionPopup = popup; - - const cm = module.settings.easyMde.codemirror; + if(!module.settings.mentionSearch) return; - cm.on('change', async () => { - const cursor = cm.getCursor(); - const textBefore = cm.getLine(cursor.line).slice(0, cursor.ch); + module.settings.easyMde.codemirror.on('change', async () => { + const cursor = module.settings.easyMde.codemirror.getCursor(); + const textBefore = module.settings.easyMde.codemirror.getLine(cursor.line).slice(0, cursor.ch); const atIdx = textBefore.lastIndexOf('@'); if (atIdx === -1) { module.hideMentionPopup(); return; } @@ -147,22 +146,33 @@ window.pos.modules.markdown = function(settings){ }); document.addEventListener('click', e => { - if (!popup.contains(e.target)) module.hideMentionPopup(); + if (!module.settings.mention.results.contains(e.target)) module.hideMentionPopup(); }); - cm.addKeyMap({ + module.settings.easyMde.codemirror.addKeyMap({ 'Esc': () => { - if (module.settings.mentionPopup?.style.display === 'block') { - module.hideMentionPopup(); - } else { - return cm.constructor.Pass; // fall through to tab-trap keymap + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; + } + }, + 'Up': () => { + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; + } + }, + 'Down': () => { + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; } }, - 'Up': () => module.moveMentionSelection(-1), - 'Down': () => module.moveMentionSelection(1), 'Enter': () => { - const active = module.settings.mentionPopup?.querySelector('li.pos-mention-active'); - if (active) { active.dispatchEvent(new MouseEvent('mousedown')); return true; } + if(module.settings.mention.popover.settings.opened){ + if(module.settings.mention.menu.contains(document.activeElement)){ + document.activeElement.click(); + } + } else { + return module.settings.easyMde.codemirror.constructor.Pass; + } } }); @@ -173,7 +183,7 @@ window.pos.modules.markdown = function(settings){ // purpose: renders mention results in the popup, positioned at the cursor // ------------------------------------------------------------------------ module.renderMentionPopup = (results) => { - const popup = module.settings.mentionPopup; + const popup = module.settings.mention.results; const cm = module.settings.easyMde.codemirror; popup.innerHTML = ''; @@ -181,40 +191,28 @@ window.pos.modules.markdown = function(settings){ results.forEach(person => { const li = document.createElement('li'); - li.textContent = person.name; - li.addEventListener('mouseenter', () => { - popup.querySelectorAll('li').forEach(el => el.classList.remove('pos-mention-active')); - li.classList.add('pos-mention-active'); - }); - li.addEventListener('mousedown', e => { e.preventDefault(); module.insertMention(person); }); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = person.name; + li.appendChild(button); + button.addEventListener('click', e => { e.preventDefault(); module.insertMention(person); }); popup.appendChild(li); }); const coords = cm.cursorCoords(true, 'window'); popup.style.left = coords.left + 'px'; popup.style.top = (coords.bottom + 4) + 'px'; - popup.style.display = 'block'; - }; + module.settings.mention.popover.buildFocusableMenuItems(); - // purpose: moves keyboard selection up/down within the mention popup - // ------------------------------------------------------------------------ - module.moveMentionSelection = (direction) => { - const popup = module.settings.mentionPopup; - if (!popup || popup.style.display === 'none') return; - const items = Array.from(popup.querySelectorAll('li')); - if (!items.length) return; - const current = items.findIndex(li => li.classList.contains('pos-mention-active')); - const next = Math.max(0, Math.min(items.length - 1, current + direction)); - items.forEach(li => li.classList.remove('pos-mention-active')); - items[next].classList.add('pos-mention-active'); + module.settings.mention.popover.open(); }; // purpose: hides the mention popup and clears state // ------------------------------------------------------------------------ module.hideMentionPopup = () => { - if (module.settings.mentionPopup) module.settings.mentionPopup.style.display = 'none'; + if(module.settings.mentionPopup) module.settings.mention.popover.close(); module.settings.mentionState = null; }; diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-popover.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-popover.js index 4bd86b6..8890438 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-popover.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-popover.js @@ -22,7 +22,7 @@ window.pos.modules.popover = function(container, userSettings = {}){ // popover trigger (dom node) module.settings.trigger = module.settings.container.querySelector('[popovertarget]'); // id used to mark the module (string) - module.settings.id = module.settings.trigger.getAttribute('popovertarget'); + module.settings.id = module.settings.trigger?.getAttribute('popovertarget') || 'pos-popover-' + Math.floor(Math.random() * 1000000); // popover content (dom node) module.settings.popover = module.settings.container.querySelector('[popover]'); // if the popover is opened (bool) @@ -45,7 +45,7 @@ window.pos.modules.popover = function(container, userSettings = {}){ if(event.newState == 'open'){ // set all the focusable menu items if(module.settings.menu){ - module.settings.focusable = Array.from(module.settings.menu.querySelectorAll('li a, li button')).filter(element => element.checkVisibility()) + module.buildFocusableMenuItems(); } // set state @@ -77,7 +77,7 @@ window.pos.modules.popover = function(container, userSettings = {}){ }); // if the popover is triggered by keyboard navigation, focus the first element - if(module.settings.menu){ + if(module.settings.menu && module.settings.trigger){ module.settings.trigger.addEventListener('keyup', event => { if(event.keyCode === 32 || event.key === 'Enter'){ if(!module.settings.opened){ @@ -183,6 +183,33 @@ window.pos.modules.popover = function(container, userSettings = {}){ }; + // purpose: opens the popover menu + // ------------------------------------------------------------------------ + module.open = () => { + module.settings.popover.showPopover(); + + pos.modules.debug(module.settings.debug, module.settings.id, 'Programatically opening popover', module.settings.container); + }; + + + // purpose: closes the popover menu + // ------------------------------------------------------------------------ + module.close = () => { + module.settings.popover.hidePopover(); + + pos.modules.debug(module.settings.debug, module.settings.id, 'Programatically closing popover', module.settings.container); + }; + + + // purpose: builds an array of all the focusable menu items + // ------------------------------------------------------------------------ + module.buildFocusableMenuItems = () => { + return module.settings.focusable = Array.from(module.settings.menu.querySelectorAll('li a, li button')).filter(element => element.checkVisibility()) + + pos.modules.debug(module.settings.debug, module.settings.id, 'Built focusable menu items list', module.settings.focusable); + } + + // purpose: focuses first visible menu item // ------------------------------------------------------------------------ module.focusFirstMenuItem = () => { diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css index ef5a9c8..edd8565 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css @@ -73,34 +73,9 @@ /* @mention popup ============================================================================ */ - .pos-mention-popup { + .pos-markdown-mention [popover] { position: fixed; z-index: 9999; - display: none; - list-style: none; - margin: 0; - padding: var(--pos-spacing-1, 4px) 0; - min-width: 200px; - max-height: 220px; - overflow-y: auto; - background: var(--pos-color-card-background, #fff); - border: 1px solid var(--pos-color-input-frame, #ccc); - border-radius: var(--pos-radius-input, 4px); - box-shadow: 0 4px 16px rgba(0, 0, 0, .12); - } - - .pos-mention-popup li { - padding: 7px 14px; - cursor: pointer; - font-size: .875rem; - color: var(--pos-color-input-text, #1a1a1a); - line-height: 1.4; - } - - .pos-mention-popup li:hover, - .pos-mention-popup li.pos-mention-active { - background: var(--pos-color-highlight-background, #f0f4ff); - color: var(--pos-color-link, inherit); } diff --git a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid index 0739d59..b56c960 100644 --- a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid +++ b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid @@ -1,32 +1,26 @@ {% doc %} @param {string} id - unique id for the module - @param {string} value - pre-filled content for the textarea - @param {number} minlength - minimum number of characters allowed - @param {number} maxlength - maximum number of characters allowed @param {string} name - name for the textarea - @param {object} presigned_upload - presigned upload data for file uploads -{% enddoc %} -{% comment %} - - Rich text editor + @param {string} value - pre-filled content for the textarea - Arguments: - - id (string) unique id for the module - - name (string, required) name for the + {% if mention_search_url %} +
+ + +
+ {% endif %} + {% render 'modules/common-styling/icon', icon: 'info' %} diff --git a/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid b/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid index 2c9c03c..551368b 100644 --- a/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid +++ b/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid @@ -332,7 +332,11 @@

Markdown editor

- {% render 'modules/common-styling/forms/markdown', id: 'styleguide-markdown-editor', name: 'styleguide-markdown-editor', value: null, minlength: null, maxlength: null, presigned_upload: null %} + {% render 'modules/common-styling/forms/markdown', + id: 'styleguide-markdown-editor', + name: 'styleguide-markdown-editor', + mention_search_url: '/api/v1/users/search?query=' + %} {% capture code %}{% raw %} {% render 'modules/common-styling/forms/markdown', diff --git a/pos-module-common-styling/platformos-tools.log b/pos-module-common-styling/platformos-tools.log new file mode 100644 index 0000000..77c1683 --- /dev/null +++ b/pos-module-common-styling/platformos-tools.log @@ -0,0 +1,6 @@ +[2026-06-29T14:44:41.183Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-29T14:44:41.191Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT +[2026-06-29T14:44:41.448Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-29T15:04:45.697Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-29T15:04:45.705Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT +[2026-06-29T15:04:45.964Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling From 5db1d4f6106af1f0b543a06eb35a606e0fe2f856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Tue, 30 Jun 2026 12:50:28 +0200 Subject: [PATCH 04/10] Styling the @mention popup --- .../views/pages/api/users/search.json.liquid | 50 ++- .../public/assets/js/pos-markdown.js | 344 ++++++++++-------- .../public/assets/style/pos-avatar.css | 1 + .../public/assets/style/pos-markdown.css | 20 + .../views/partials/forms/markdown.liquid | 13 +- .../views/partials/style-guide/forms.liquid | 2 +- .../platformos-tools.log | 10 + 7 files changed, 270 insertions(+), 170 deletions(-) diff --git a/pos-module-common-styling/app/views/pages/api/users/search.json.liquid b/pos-module-common-styling/app/views/pages/api/users/search.json.liquid index dcb52e8..010b932 100644 --- a/pos-module-common-styling/app/views/pages/api/users/search.json.liquid +++ b/pos-module-common-styling/app/views/pages/api/users/search.json.liquid @@ -1,12 +1,38 @@ -[ - { - "id": "1", - "name": "John Kowalski", - "avatar": "" - }, - { - "id": "2", - "name": "Jane Doe", - "avatar": "" - } -] \ No newline at end of file +{ + "total_entries": 3, + "results": [ + { + "name": "JJ Bragg", + "id": "6", + "avatar": { + "photo_width": 200, + "photo_height": 200, + "photo": { + "url": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/a86672a06357f944ad9e0c9b5def8828.jpg", + "versions": { + "m": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/m_a86672a06357f944ad9e0c9b5def8828.jpg", + "lg": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/lg_a86672a06357f944ad9e0c9b5def8828.jpg", + "sm": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/sm_a86672a06357f944ad9e0c9b5def8828.jpg", + "xl": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/xl_a86672a06357f944ad9e0c9b5def8828.jpg", + "m_avif": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/m_avif_a86672a06357f944ad9e0c9b5def8828.avif", + "lg_avif": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/lg_avif_a86672a06357f944ad9e0c9b5def8828.avif", + "sm_avif": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/sm_avif_a86672a06357f944ad9e0c9b5def8828.avif", + "xl_avif": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/xl_avif_a86672a06357f944ad9e0c9b5def8828.avif", + "uncropped": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/uncropped_a86672a06357f944ad9e0c9b5def8828.jpg", + "uncropped_avif": "https://uploads.prod01.oregon.platform-os.com/instances/2268/property_uploads/modules/community/photo/photo/b7dd4f75-7032-4ab3-9ca5-a52add9a9aea/uncropped_avif_a86672a06357f944ad9e0c9b5def8828.avif" + } + } + } + }, + { + "name": "John Kowalski", + "id": "1", + "avatar": {} + }, + { + "name": "Jane Doe", + "id": "3", + "avatar": {} + } + ] +} \ No newline at end of file diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 2957494..4b2e0a8 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -31,22 +31,29 @@ window.pos.modules.markdown = function(settings){ // class name that hides error on the list (string) module.settings.errorDisabledClass = 'pos-markdown-error-disabled'; + // @mention object (object) + module.mention = {}; // @mention settings (object) module.settings.mention = {}; // instance of the popover with @mentions (object) module.settings.mention.popover = pos.modules.active[`${module.settings.id}-mention-popover`]; // list with @mention results (dom node) module.settings.mention.results = module.settings.container.querySelector(`#${module.settings.id}-mention-popover`); + // url of the api to fetch mention results (string) + module.settings.mention.url = module.settings.mention.popover?.settings.container.dataset.url || null; + // active @mention state { query, line, atCh } or null + module.settings.mention.state = null; + // template for @mention result (dom node) + module.settings.mention.template = module.settings.mention.popover?.settings.container.querySelector('template'); + // async function(query) => [{ id, name }] — function to fetch mention results, returns array of objects with id, name and avatar + module.settings.mention.search = async function(query){ return fetch(module.settings.mention.url + '?query=' + encodeURIComponent(query)).then(response => response.json()).then(data => data.results) }; // debug mode enabled (bool) module.settings.debug = typeof settings.debug === 'boolean' ? settings.debug : false; // easymde instance (object) module.settings.easyMde = null; - // async function(query) => [{ id, name }] — provided by consumer, enables @mentions (optional) - module.settings.mentionSearch = settings.mentionSearch || async function(query){ return Promise.resolve([{"name": "john doe", "c__names": "john doe johndoe@gmail.com","id": "11"},{"name": "łukasz krysiewicz", "c__names": "łukasz krysiewicz zx@secondgate.com","id": "15"},{"name": "jj bragg", "c__names": "jj bragg jjbragg@platformos.com","id": "18"}]); }; - // active @mention state { query, line, atCh } or null - module.settings.mentionState = null; + @@ -115,158 +122,12 @@ window.pos.modules.markdown = function(settings){ } }, true); // capture phase — fires before codemirror's listener on the inner textarea - pos.modules.debug(module.settings.debug, module.settings.id, 'EasyMDE instance created', module.settings.easyMde); - - module.startAtMentions(); - }; - - - - // purpose: sets up @mention detection and popup - // ------------------------------------------------------------------------ - module.startAtMentions = () => { - if(!module.settings.mentionSearch) return; - - module.settings.easyMde.codemirror.on('change', async () => { - const cursor = module.settings.easyMde.codemirror.getCursor(); - const textBefore = module.settings.easyMde.codemirror.getLine(cursor.line).slice(0, cursor.ch); - const atIdx = textBefore.lastIndexOf('@'); - - if (atIdx === -1) { module.hideMentionPopup(); return; } - - const charBefore = atIdx > 0 ? textBefore[atIdx - 1] : ' '; - const query = textBefore.slice(atIdx + 1); - - if (!/\s/.test(charBefore) && atIdx > 0) { module.hideMentionPopup(); return; } - if (query.length < 1 || /\s/.test(query)) { module.hideMentionPopup(); return; } - - module.settings.mentionState = { query, line: cursor.line, atCh: atIdx }; - const results = await module.settings.mentionSearch(query); - module.renderMentionPopup(results); - }); - - document.addEventListener('click', e => { - if (!module.settings.mention.results.contains(e.target)) module.hideMentionPopup(); - }); - - module.settings.easyMde.codemirror.addKeyMap({ - 'Esc': () => { - if(!module.settings.mention.popover.settings.opened){ - return module.settings.easyMde.codemirror.constructor.Pass; - } - }, - 'Up': () => { - if(!module.settings.mention.popover.settings.opened){ - return module.settings.easyMde.codemirror.constructor.Pass; - } - }, - 'Down': () => { - if(!module.settings.mention.popover.settings.opened){ - return module.settings.easyMde.codemirror.constructor.Pass; - } - }, - 'Enter': () => { - if(module.settings.mention.popover.settings.opened){ - if(module.settings.mention.menu.contains(document.activeElement)){ - document.activeElement.click(); - } - } else { - return module.settings.easyMde.codemirror.constructor.Pass; - } - } - }); - - module.reapplyMentionMarks(); - }; - - - // purpose: renders mention results in the popup, positioned at the cursor - // ------------------------------------------------------------------------ - module.renderMentionPopup = (results) => { - const popup = module.settings.mention.results; - const cm = module.settings.easyMde.codemirror; - popup.innerHTML = ''; - - if (!results?.length) { module.hideMentionPopup(); return; } - - results.forEach(person => { - const li = document.createElement('li'); - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = person.name; - li.appendChild(button); - button.addEventListener('click', e => { e.preventDefault(); module.insertMention(person); }); - popup.appendChild(li); - }); - - const coords = cm.cursorCoords(true, 'window'); - popup.style.left = coords.left + 'px'; - popup.style.top = (coords.bottom + 4) + 'px'; - - module.settings.mention.popover.buildFocusableMenuItems(); - - module.settings.mention.popover.open(); - }; - - - // purpose: hides the mention popup and clears state - // ------------------------------------------------------------------------ - module.hideMentionPopup = () => { - if(module.settings.mentionPopup) module.settings.mention.popover.close(); - module.settings.mentionState = null; - }; - - - // purpose: collapses "[" and "](id)" in a single mention so only "@Name" is visible - // ------------------------------------------------------------------------ - module.applyMentionMark = (line, atCh, name, id) => { - const cm = module.settings.easyMde.codemirror; - // @[Name](id) — collapse "[" (1 char) so "@Name" is visible - cm.markText( - { line, ch: atCh + 1 }, - { line, ch: atCh + 2 }, - { collapsed: true, atomic: true } - ); - // collapse "](id)" (id.length + 3 chars) so nothing after the name is visible - cm.markText( - { line, ch: atCh + 2 + name.length }, - { line, ch: atCh + 2 + name.length + id.length + 3 }, - { collapsed: true, atomic: true } - ); - }; - - - // purpose: re-applies mention marks on existing content (init or programmatic value set) - // ------------------------------------------------------------------------ - module.reapplyMentionMarks = () => { - if (!module.settings.mentionSearch) return; - const cm = module.settings.easyMde.codemirror; - const regex = /@\[([^\]]+)\]\(([^)]+)\)/g; - for (let line = 0; line < cm.lineCount(); line++) { - regex.lastIndex = 0; - let match; - while ((match = regex.exec(cm.getLine(line))) !== null) { - module.applyMentionMark(line, match.index, match[1], match[2]); - } + // purpose: sets up @mention detection and popup + if(module.settings.mention.url){ + module.mention.start(); } - }; - - // purpose: replaces the @query text with the selected mention - // ------------------------------------------------------------------------ - module.insertMention = (person) => { - const cm = module.settings.easyMde.codemirror; - const s = module.settings.mentionState; - if (!s) return; - const cursor = cm.getCursor(); - cm.replaceRange( - `@[${person.name}](${person.id}) `, - { line: s.line, ch: s.atCh }, - { line: cursor.line, ch: cursor.ch } - ); - module.applyMentionMark(s.line, s.atCh, person.name, person.id); - module.hideMentionPopup(); - cm.focus(); + pos.modules.debug(module.settings.debug, module.settings.id, 'EasyMDE instance created', module.settings.easyMde); }; @@ -356,7 +217,7 @@ window.pos.modules.markdown = function(settings){ module.value = (value) => { if(value){ module.settings.easyMde.value(value); - module.reapplyMentionMarks(); + module.mention.reapplyMarks(); pos.modules.debug(module.settings.debug, module.settings.id, 'Changed editor content', value); // dispatch custom event @@ -390,7 +251,6 @@ window.pos.modules.markdown = function(settings){ }; - // purpose: validates the value // ------------------------------------------------------------------------ module.validate = event => { @@ -429,6 +289,178 @@ window.pos.modules.markdown = function(settings){ }; + // purpose: sets up @mention detection and popup + // ------------------------------------------------------------------------ + module.mention.start = () => { + module.settings.easyMde.codemirror.on('change', async () => { + const cursor = module.settings.easyMde.codemirror.getCursor(); + const textBefore = module.settings.easyMde.codemirror.getLine(cursor.line).slice(0, cursor.ch); + const atIdx = textBefore.lastIndexOf('@'); + + if(atIdx === -1){ + module.mention.hide(); + return; + } + + const charBefore = atIdx > 0 ? textBefore[atIdx - 1] : ' '; + const query = textBefore.slice(atIdx + 1); + + if(!/\s/.test(charBefore) && atIdx > 0){ + module.mention.hide(); + return; + } + if(query.length < 1 || /\s/.test(query)){ + module.mention.hide(); + return; + } + + module.settings.mention.state = { query, line: cursor.line, atCh: atIdx }; + const results = await module.settings.mention.search(query); + module.mention.renderPopup(results); + }); + + document.addEventListener('click', e => { + if(!module.settings.mention.results.contains(e.target)){ + module.mention.hide(); + } + }); + + module.settings.easyMde.codemirror.addKeyMap({ + 'Esc': () => { + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; + } + }, + 'Up': () => { + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; + } + }, + 'Down': () => { + if(!module.settings.mention.popover.settings.opened){ + return module.settings.easyMde.codemirror.constructor.Pass; + } + }, + 'Enter': () => { + if(module.settings.mention.popover.settings.opened){ + if(module.settings.mention.menu.contains(document.activeElement)){ + document.activeElement.click(); + } + } else { + return module.settings.easyMde.codemirror.constructor.Pass; + } + } + }); + + module.mention.reapplyMarks(); + }; + + + // purpose: renders mention results in the popup, positioned at the cursor + // ------------------------------------------------------------------------ + module.mention.renderPopup = (results) => { + module.settings.mention.results.innerHTML = ''; + + if(!results?.length){ + module.mention.hide(); + return; + } + + results.forEach(person => { + const template = module.settings.mention.template.content.cloneNode(true); + + if(person.avatar.photo){ + template.querySelector('img').src = person.avatar.photo.versions.sm; + template.querySelector('.pos-markdown-mention-avatar-initials').remove(); + } else { + template.querySelector('img').remove(); + const names = person.name.split(' '); + template.querySelector('.pos-markdown-mention-avatar-initials').textContent = names[0][0] + names[1][0]; + } + template.querySelector('.pos-markdown-mention-name').textContent = person.name; + + template.querySelector('button').addEventListener('click', e => { + e.preventDefault(); + module.mention.insert(person); + }); + + module.settings.mention.results.appendChild(template); + }); + + const atPos = { line: module.settings.mention.state.line, ch: module.settings.mention.state.atCh }; + const coords = module.settings.easyMde.codemirror.charCoords(atPos, 'window'); + module.settings.mention.results.style.left = coords.left + 'px'; + module.settings.mention.results.style.top = (coords.bottom + 4) + 'px'; + + module.settings.mention.popover.buildFocusableMenuItems(); + + module.settings.mention.popover.open(); + }; + + + // purpose: hides the mention popup and clears state + // ------------------------------------------------------------------------ + module.mention.hide = () => { + if(module.settings.mention.popover){ + module.settings.mention.popover.close(); + } + module.settings.mention.state = null; + }; + + + // purpose: collapses "[" and "](id)" in a single mention so only "@Name" is visible + // ------------------------------------------------------------------------ + module.mention.applyMark = (line, atCh, name, id) => { + const cm = module.settings.easyMde.codemirror; + // @[Name](id) — collapse "[" (1 char) so "@Name" is visible + cm.markText( + { line, ch: atCh + 1 }, + { line, ch: atCh + 2 }, + { collapsed: true, atomic: true } + ); + // collapse "](id)" (id.length + 3 chars) so nothing after the name is visible + cm.markText( + { line, ch: atCh + 2 + name.length }, + { line, ch: atCh + 2 + name.length + id.length + 3 }, + { collapsed: true, atomic: true } + ); + }; + + + // purpose: re-applies mention marks on existing content (init or programmatic value set) + // ------------------------------------------------------------------------ + module.mention.reapplyMarks = () => { + const cm = module.settings.easyMde.codemirror; + const regex = /@\[([^\]]+)\]\(([^)]+)\)/g; + for (let line = 0; line < cm.lineCount(); line++) { + regex.lastIndex = 0; + let match; + while ((match = regex.exec(cm.getLine(line))) !== null) { + module.mention.applyMark(line, match.index, match[1], match[2]); + } + } + }; + + + // purpose: replaces the @query text with the selected mention + // ------------------------------------------------------------------------ + module.mention.insert = (person) => { + const cm = module.settings.easyMde.codemirror; + const s = module.settings.mention.state; + if (!s) return; + const cursor = cm.getCursor(); + cm.replaceRange( + `@[${person.name}](${person.id}) `, + { line: s.line, ch: s.atCh }, + { line: cursor.line, ch: cursor.ch } + ); + module.mention.applyMark(s.line, s.atCh, person.name, person.id); + module.mention.hide(); + cm.focus(); + }; + + + module.init(); }; \ No newline at end of file diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-avatar.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-avatar.css index 87fa2f2..93c644d 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-avatar.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-avatar.css @@ -25,6 +25,7 @@ background-color: var(--pos-color-frame); line-height: 0; + text-transform: uppercase; font-weight: 500; color: var(--pos-color-content-text-supplementary); } diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css index edd8565..668b0af 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css @@ -14,6 +14,7 @@ ============================================================================ */ .pos-markdown { width: 100%; + position: relative; } .EasyMDEContainer .editor-toolbar { @@ -74,10 +75,29 @@ /* @mention popup ============================================================================ */ .pos-markdown-mention [popover] { + overflow: visible; + position: fixed; z-index: 9999; } + .pos-markdown-mention [popover]:after { + width: 16px; + height: 11px; + position: absolute; + top: -11px; + left: 0; + + clip-path: polygon(50% 0%, 0% 100%, 100% 100%); + background-color: var(--pos-color-interactive); + + content: ''; + } + + .pos-markdown-mention menu li button { + padding: .5rem .75rem; + } + /* content ============================================================================ */ diff --git a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid index b56c960..ed1eddb 100644 --- a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid +++ b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid @@ -37,7 +37,18 @@ >{{ value }} {% if mention_search_url %} -
+
+
diff --git a/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid b/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid index 551368b..16423a0 100644 --- a/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid +++ b/pos-module-common-styling/modules/common-styling/public/views/partials/style-guide/forms.liquid @@ -335,7 +335,7 @@ {% render 'modules/common-styling/forms/markdown', id: 'styleguide-markdown-editor', name: 'styleguide-markdown-editor', - mention_search_url: '/api/v1/users/search?query=' + mention_search_url: '/api/users/search?query=' %} {% capture code %}{% raw %} diff --git a/pos-module-common-styling/platformos-tools.log b/pos-module-common-styling/platformos-tools.log index 77c1683..047a75f 100644 --- a/pos-module-common-styling/platformos-tools.log +++ b/pos-module-common-styling/platformos-tools.log @@ -4,3 +4,13 @@ [2026-06-29T15:04:45.697Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling [2026-06-29T15:04:45.705Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT [2026-06-29T15:04:45.964Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-30T10:34:50.587Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-30T10:34:50.596Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT +[2026-06-30T10:34:51.027Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-30T10:36:05.631Z] auto-diagnostics triggered by Read: X:\2026, platformos, modules\pos-modules\pos-module-common-styling\app\views\pages\api\users\search.json.liquid +[2026-06-30T10:36:05.632Z] runCheck: X:\2026, platformos, modules\pos-modules\pos-module-common-styling\app\views\pages\api\users\search.json.liquid +[2026-06-30T10:36:05.636Z] auto-diagnostics: +pos-cli check failed: spawn pos-cli ENOENT +[2026-06-30T10:38:56.053Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-30T10:38:56.060Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT +[2026-06-30T10:38:56.317Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling From 36fe2b48c6901b7bfe6d13c674342b0ce41ac72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Tue, 30 Jun 2026 13:22:30 +0200 Subject: [PATCH 05/10] Better popup position --- .../public/assets/js/pos-markdown.js | 27 ++++++++++++++++--- .../public/assets/style/pos-markdown.css | 14 ++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 4b2e0a8..7e9b38f 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -352,6 +352,15 @@ window.pos.modules.markdown = function(settings){ } }); + // reposition popup when the page or the editor itself scrolls + window.addEventListener('scroll', () => { + if(module.settings.mention.popover?.settings.opened) module.mention.updatePopupPosition(); + }, { passive: true }); + + module.settings.easyMde.codemirror.on('scroll', () => { + if(module.settings.mention.popover?.settings.opened) module.mention.updatePopupPosition(); + }); + module.mention.reapplyMarks(); }; @@ -387,10 +396,7 @@ window.pos.modules.markdown = function(settings){ module.settings.mention.results.appendChild(template); }); - const atPos = { line: module.settings.mention.state.line, ch: module.settings.mention.state.atCh }; - const coords = module.settings.easyMde.codemirror.charCoords(atPos, 'window'); - module.settings.mention.results.style.left = coords.left + 'px'; - module.settings.mention.results.style.top = (coords.bottom + 4) + 'px'; + module.mention.updatePopupPosition(); module.settings.mention.popover.buildFocusableMenuItems(); @@ -398,6 +404,19 @@ window.pos.modules.markdown = function(settings){ }; + // purpose: recalculates popup position anchored to the @ character (fixed to viewport) + // ------------------------------------------------------------------------ + module.mention.updatePopupPosition = () => { + if(!module.settings.mention.state){ + return; + } + const atPos = { line: module.settings.mention.state.line, ch: module.settings.mention.state.atCh }; + const coords = module.settings.easyMde.codemirror.charCoords(atPos, 'window'); + module.settings.mention.results.style.left = coords.left + 'px'; + module.settings.mention.results.style.top = (coords.bottom + 4) + 'px'; + }; + + // purpose: hides the mention popup and clears state // ------------------------------------------------------------------------ module.mention.hide = () => { diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css index 668b0af..6514f2d 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css @@ -75,10 +75,10 @@ /* @mention popup ============================================================================ */ .pos-markdown-mention [popover] { + margin-inline-start: calc(var(--pos-radius-card) * -2); overflow: visible; position: fixed; - z-index: 9999; } .pos-markdown-mention [popover]:after { @@ -86,7 +86,7 @@ height: 11px; position: absolute; top: -11px; - left: 0; + inset-inline-start: calc(var(--pos-radius-card) * 2); clip-path: polygon(50% 0%, 0% 100%, 100% 100%); background-color: var(--pos-color-interactive); @@ -98,6 +98,16 @@ padding: .5rem .75rem; } + .pos-markdown-mention menu li:first-child button { + border-start-start-radius: var(--pos-radius-card); + border-start-end-radius: var(--pos-radius-card); + } + + .pos-markdown-mention menu li:last-child button { + border-end-start-radius: var(--pos-radius-card); + border-end-end-radius: var(--pos-radius-card); + } + /* content ============================================================================ */ From f01266cad6a1dca90ada6f32083b82336abc66d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Tue, 30 Jun 2026 13:43:06 +0200 Subject: [PATCH 06/10] @mention tag styling --- .../public/assets/js/pos-markdown.js | 28 ++++++++++++++++--- .../public/assets/style/pos-markdown.css | 7 +++++ .../platformos-tools.log | 16 ----------- 3 files changed, 31 insertions(+), 20 deletions(-) delete mode 100644 pos-module-common-styling/platformos-tools.log diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 7e9b38f..1ca6e3e 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -292,7 +292,7 @@ window.pos.modules.markdown = function(settings){ // purpose: sets up @mention detection and popup // ------------------------------------------------------------------------ module.mention.start = () => { - module.settings.easyMde.codemirror.on('change', async () => { + module.settings.easyMde.codemirror.on('change', () => { const cursor = module.settings.easyMde.codemirror.getCursor(); const textBefore = module.settings.easyMde.codemirror.getLine(cursor.line).slice(0, cursor.ch); const atIdx = textBefore.lastIndexOf('@'); @@ -315,8 +315,11 @@ window.pos.modules.markdown = function(settings){ } module.settings.mention.state = { query, line: cursor.line, atCh: atIdx }; - const results = await module.settings.mention.search(query); - module.mention.renderPopup(results); + clearTimeout(module.mention.searchTimeout); + module.mention.searchTimeout = setTimeout(async () => { + const results = await module.settings.mention.search(query); + module.mention.renderPopup(results); + }, 300); }); document.addEventListener('click', e => { @@ -362,12 +365,16 @@ window.pos.modules.markdown = function(settings){ }); module.mention.reapplyMarks(); + + pos.modules.debug(module.settings.debug, module.settings.id, 'Activated @mentions', module.settings.mention); }; // purpose: renders mention results in the popup, positioned at the cursor // ------------------------------------------------------------------------ module.mention.renderPopup = (results) => { + pos.modules.debug(module.settings.debug, module.settings.id, 'Rendering @mention results in the popover', results); + module.settings.mention.results.innerHTML = ''; if(!results?.length){ @@ -410,6 +417,9 @@ window.pos.modules.markdown = function(settings){ if(!module.settings.mention.state){ return; } + + pos.modules.debug(module.settings.debug, module.settings.id, 'Updating @mention popover position', module.settings.mention.state); + const atPos = { line: module.settings.mention.state.line, ch: module.settings.mention.state.atCh }; const coords = module.settings.easyMde.codemirror.charCoords(atPos, 'window'); module.settings.mention.results.style.left = coords.left + 'px'; @@ -420,7 +430,7 @@ window.pos.modules.markdown = function(settings){ // purpose: hides the mention popup and clears state // ------------------------------------------------------------------------ module.mention.hide = () => { - if(module.settings.mention.popover){ + if(module.settings.mention.popover && module.settings.mention.popover.settings.opened){ module.settings.mention.popover.close(); } module.settings.mention.state = null; @@ -443,12 +453,20 @@ window.pos.modules.markdown = function(settings){ { line, ch: atCh + 2 + name.length + id.length + 3 }, { collapsed: true, atomic: true } ); + // style the visible "@Name" span + cm.markText( + { line, ch: atCh }, + { line, ch: atCh + 2 + name.length }, + { className: 'pos-markdown-mention-mark' } + ); }; // purpose: re-applies mention marks on existing content (init or programmatic value set) // ------------------------------------------------------------------------ module.mention.reapplyMarks = () => { + pos.modules.debug(module.settings.debug, module.settings.id, 'Re-applying @mention marks', module.settings.mention); + const cm = module.settings.easyMde.codemirror; const regex = /@\[([^\]]+)\]\(([^)]+)\)/g; for (let line = 0; line < cm.lineCount(); line++) { @@ -464,6 +482,8 @@ window.pos.modules.markdown = function(settings){ // purpose: replaces the @query text with the selected mention // ------------------------------------------------------------------------ module.mention.insert = (person) => { + pos.modules.debug(module.settings.debug, module.settings.id, 'Inserting @mention to the editor', person); + const cm = module.settings.easyMde.codemirror; const s = module.settings.mention.state; if (!s) return; diff --git a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css index 6514f2d..8c0f643 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css +++ b/pos-module-common-styling/modules/common-styling/public/assets/style/pos-markdown.css @@ -108,6 +108,13 @@ border-end-end-radius: var(--pos-radius-card); } + /* @mention mark inside the editor */ + .EasyMDEContainer .CodeMirror .pos-markdown-mention-mark { + font-weight: 600 !important; + color: var(--pos-color-interactive-active) !important; + text-decoration: none !important; + } + /* content ============================================================================ */ diff --git a/pos-module-common-styling/platformos-tools.log b/pos-module-common-styling/platformos-tools.log deleted file mode 100644 index 047a75f..0000000 --- a/pos-module-common-styling/platformos-tools.log +++ /dev/null @@ -1,16 +0,0 @@ -[2026-06-29T14:44:41.183Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-29T14:44:41.191Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT -[2026-06-29T14:44:41.448Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-29T15:04:45.697Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-29T15:04:45.705Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT -[2026-06-29T15:04:45.964Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-30T10:34:50.587Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-30T10:34:50.596Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT -[2026-06-30T10:34:51.027Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-30T10:36:05.631Z] auto-diagnostics triggered by Read: X:\2026, platformos, modules\pos-modules\pos-module-common-styling\app\views\pages\api\users\search.json.liquid -[2026-06-30T10:36:05.632Z] runCheck: X:\2026, platformos, modules\pos-modules\pos-module-common-styling\app\views\pages\api\users\search.json.liquid -[2026-06-30T10:36:05.636Z] auto-diagnostics: -pos-cli check failed: spawn pos-cli ENOENT -[2026-06-30T10:38:56.053Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling -[2026-06-30T10:38:56.060Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT -[2026-06-30T10:38:56.317Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling From 23bbdd14555be538a9bdaa1c5077c6a0e8fe6122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Tue, 30 Jun 2026 16:55:09 +0200 Subject: [PATCH 07/10] Clean up @mention when pressing backspace --- .../public/assets/js/pos-markdown.js | 70 +++++++++++++++++++ .../platformos-tools.log | 3 + 2 files changed, 73 insertions(+) create mode 100644 pos-module-common-styling/platformos-tools.log diff --git a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js index 1ca6e3e..8310a47 100644 --- a/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js +++ b/pos-module-common-styling/modules/common-styling/public/assets/js/pos-markdown.js @@ -293,6 +293,10 @@ window.pos.modules.markdown = function(settings){ // ------------------------------------------------------------------------ module.mention.start = () => { module.settings.easyMde.codemirror.on('change', () => { + if (module.mention.cleaningUp) return; + + module.mention.cleanupIfEditingMark(); + const cursor = module.settings.easyMde.codemirror.getCursor(); const textBefore = module.settings.easyMde.codemirror.getLine(cursor.line).slice(0, cursor.ch); const atIdx = textBefore.lastIndexOf('@'); @@ -437,6 +441,72 @@ window.pos.modules.markdown = function(settings){ }; + // purpose: when a change lands inside an existing mention mark, strips @[name](id) to plain @name + // so backspacing or typing within the visible "@name" doesn't leave hidden markdown syntax + // ------------------------------------------------------------------------ + module.mention.cleanupIfEditingMark = () => { + const cm = module.settings.easyMde.codemirror; + const cursor = cm.getCursor(); + + // findMarksAt uses exclusive right boundary, so also check one char left to catch right-edge deletions + const positions = [cursor]; + if (cursor.ch > 0) positions.push({ line: cursor.line, ch: cursor.ch - 1 }); + + let mentionMark = null; + for (const pos of positions) { + mentionMark = cm.findMarksAt(pos).find(m => m.className === 'pos-markdown-mention-mark'); + if (mentionMark) break; + } + if (!mentionMark) return; + + const range = mentionMark.find(); + if (!range) return; + + const underlyingText = cm.getRange(range.from, range.to); + if (!underlyingText.startsWith('@')) return; + + // extract the visible name from @[Name (collapse removes [, but it's still in underlying text) + const displayName = underlyingText.startsWith('@[') ? underlyingText.slice(2) : underlyingText.slice(1); + + // find and clear the closing ](id) collapsed mark (starts exactly where the styling mark ends) + let endPos = range.to; + const allMarks = cm.getAllMarks(); + for (const m of allMarks) { + if (m === mentionMark || m.className) continue; + const mRange = m.find(); + if (!mRange || !m.collapsed) continue; + if (mRange.from.line === range.to.line && mRange.from.ch === range.to.ch) { + endPos = mRange.to; + m.clear(); + break; + } + } + + // find and clear the opening [ collapsed mark (one char after the @ ) + for (const m of cm.getAllMarks()) { + if (m === mentionMark || m.className) continue; + const mRange = m.find(); + if (!mRange || !m.collapsed) continue; + if (mRange.from.line === range.from.line && mRange.from.ch === range.from.ch + 1) { + m.clear(); + break; + } + } + + mentionMark.clear(); + + // replace @[name](id) with plain @name; guard flag prevents re-entry on the change this fires + const origCh = cursor.ch; + module.mention.cleaningUp = true; + cm.replaceRange('@' + displayName, range.from, endPos); + module.mention.cleaningUp = false; + + // restore cursor: removing the hidden '[' shifts positions after it back by one + const newCh = origCh > range.from.ch + 1 ? origCh - 1 : origCh; + cm.setCursor({ line: range.from.line, ch: newCh }); + }; + + // purpose: collapses "[" and "](id)" in a single mention so only "@Name" is visible // ------------------------------------------------------------------------ module.mention.applyMark = (line, atCh, name, id) => { diff --git a/pos-module-common-styling/platformos-tools.log b/pos-module-common-styling/platformos-tools.log new file mode 100644 index 0000000..a3432a3 --- /dev/null +++ b/pos-module-common-styling/platformos-tools.log @@ -0,0 +1,3 @@ +[2026-06-30T14:39:50.717Z] [platformos-tools] lsp-mcp-server started: ROOT=X:\2026, platformos, modules\pos-modules\pos-module-common-styling +[2026-06-30T14:39:50.728Z] [platformos-tools] LSP init failed: spawn pos-cli ENOENT +[2026-06-30T14:39:51.028Z] session started: cwd=X:\2026, platformos, modules\pos-modules\pos-module-common-styling From b30b0d4402fda255dba916e2211fc52a2840c6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Krysiewicz?= Date: Tue, 30 Jun 2026 17:06:49 +0200 Subject: [PATCH 08/10] Code cleanup --- .../public/views/partials/forms/markdown.liquid | 14 ++++++++++++-- .../public/views/partials/icon.liquid | 14 +++++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid index ed1eddb..2d25fd7 100644 --- a/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid +++ b/pos-module-common-styling/modules/common-styling/public/views/partials/forms/markdown.liquid @@ -1,4 +1,6 @@ {% doc %} + Renders an rich-text markdown editor with optional @mention support and file uploads. + @param {string} id - unique id for the module @param {string} name - name for the textarea @param {string} value - pre-filled content for the textarea @@ -8,7 +10,15 @@ @param {number} minlength - minimum number of characters allowed @param {number} maxlength - maximum number of characters allowed - @param {mention_search_url} mention_search_url - URL to search for @mentions, e.g. `/api/v1/users/search?query=` + @param {string} mention_search_url - URL to search for @mentions, e.g. `/api/v1/users/search?query=` + + + @example + {% render 'modules/common-styling/forms/markdown', + id: 'markdown-editor', + name: 'markdown-editor', + presigned_upload: presigned_upload + %} {% enddoc %} {% liquid @@ -37,7 +47,7 @@ >{{ value }} {% if mention_search_url %} -
+