Add markup inserter functionality and render markup filter for enhanced text editing
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
// Simple markup inserter module
|
||||||
|
(function(global){
|
||||||
|
function insertAtCursor(el, text){
|
||||||
|
if(!el) return;
|
||||||
|
const start = el.selectionStart || 0;
|
||||||
|
const end = el.selectionEnd || 0;
|
||||||
|
const val = el.value || '';
|
||||||
|
el.value = val.slice(0, start) + text + val.slice(end);
|
||||||
|
el.selectionStart = el.selectionEnd = start + text.length;
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolbar(){
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'btn-group';
|
||||||
|
const makeBtn = function(type, text){
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
|
||||||
|
b.dataset.type = type; b.textContent = text; return b;
|
||||||
|
};
|
||||||
|
group.appendChild(makeBtn('case','Insert case'));
|
||||||
|
group.appendChild(makeBtn('finding','Insert finding'));
|
||||||
|
group.appendChild(makeBtn('displayset','Insert displayset'));
|
||||||
|
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
|
||||||
|
wrap.appendChild(group); wrap.appendChild(hint);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachToolbarToTextarea(textarea){
|
||||||
|
if(!textarea) return null;
|
||||||
|
// avoid double attaching
|
||||||
|
if(textarea._markupToolbarAttached) return null;
|
||||||
|
const toolbar = createToolbar();
|
||||||
|
textarea.parentNode.insertBefore(toolbar, textarea);
|
||||||
|
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
|
||||||
|
btn.addEventListener('click', function(){
|
||||||
|
const t = btn.dataset.type;
|
||||||
|
if(t === 'case'){
|
||||||
|
const id = prompt('Case ID to link to:'); if(!id) return; insertAtCursor(textarea, '[['+t+':'+id+']]' );
|
||||||
|
} else if(t === 'finding'){
|
||||||
|
const id = prompt('Finding ID to link to:'); if(!id) return; insertAtCursor(textarea, '[['+t+':'+id+']]' );
|
||||||
|
} else if(t === 'displayset'){
|
||||||
|
const caseId = prompt('Case ID containing the display set:'); if(!caseId) return; const setName = prompt('Display set name:'); if(!setName) return; insertAtCursor(textarea, '[['+t+':'+caseId+':'+setName+']]' );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
textarea._markupToolbarAttached = true;
|
||||||
|
return toolbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initForNames(names){
|
||||||
|
if(!Array.isArray(names)) return;
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
names.forEach(function(name){
|
||||||
|
const el = document.querySelector('textarea[name="'+name+'"]');
|
||||||
|
if(el) attachToolbarToTextarea(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachToSelector(selector){
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
document.querySelectorAll(selector).forEach(function(el){
|
||||||
|
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||||
|
// find next textarea sibling
|
||||||
|
let ta = ph.nextElementSibling;
|
||||||
|
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterSwap', function(evt){
|
||||||
|
try{
|
||||||
|
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||||
|
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||||
|
let ta = ph.nextElementSibling;
|
||||||
|
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||||
|
});
|
||||||
|
}catch(e){/* ignore */}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose API
|
||||||
|
global.markupInserter = {
|
||||||
|
attachToolbarToTextarea: attachToolbarToTextarea,
|
||||||
|
initForNames: initForNames,
|
||||||
|
attachToSelector: attachToSelector
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window);
|
||||||
@@ -216,4 +216,11 @@
|
|||||||
{{ caseresource_formset.empty_form | crispy}}
|
{{ caseresource_formset.empty_form | crispy}}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
if(window.markupInserter && typeof markupInserter.initForNames === 'function'){
|
||||||
|
markupInserter.initForNames(['description','history','discussion','report','notes']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -219,4 +219,63 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Global handler for markup links inserted by render_markup
|
||||||
|
document.body.addEventListener('click', function(ev){
|
||||||
|
var el = ev.target.closest && ev.target.closest('.markup-link');
|
||||||
|
if(!el) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
try{
|
||||||
|
var typ = el.getAttribute('data-type');
|
||||||
|
var args = el.getAttribute('data-args');
|
||||||
|
var href = el.getAttribute('href') || '#';
|
||||||
|
|
||||||
|
if(typ === 'case'){
|
||||||
|
// open case — give option to open in new tab
|
||||||
|
if(window.confirm('Open case in new tab?')){
|
||||||
|
window.open(href, '_blank');
|
||||||
|
} else {
|
||||||
|
window.location = href;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For finding/displayset open modal with basic options — endpoints can be wired later
|
||||||
|
var modal = document.getElementById('case-field-modal');
|
||||||
|
var content = modal && modal.querySelector('.modal-content');
|
||||||
|
var parsedArgs = [];
|
||||||
|
try{ parsedArgs = JSON.parse(args || '[]'); }catch(e){ parsedArgs = []; }
|
||||||
|
|
||||||
|
if(typ === 'finding'){
|
||||||
|
var findingId = parsedArgs[0] || '';
|
||||||
|
var html = '<div class="modal-header"><h5 class="modal-title">Finding '+findingId+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||||
|
html += '<div class="modal-body">';
|
||||||
|
html += '<p>This will open the finding viewer for ID '+findingId+'.</p>';
|
||||||
|
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/finding/'+findingId+'" target="_blank">Open in new tab</a> ';
|
||||||
|
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||||
|
html += '</div>';
|
||||||
|
if(content) content.innerHTML = html;
|
||||||
|
var bs = new bootstrap.Modal(modal);
|
||||||
|
bs.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typ === 'displayset'){
|
||||||
|
var caseId = parsedArgs[0] || '';
|
||||||
|
var setName = parsedArgs[1] || '';
|
||||||
|
var html = '<div class="modal-header"><h5 class="modal-title">Display set '+setName+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||||
|
html += '<div class="modal-body">';
|
||||||
|
html += '<p>Display set <strong>'+setName+'</strong> from case #'+caseId+'.</p>';
|
||||||
|
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/collection/'+caseId+'" target="_blank">Open case in new tab</a> ';
|
||||||
|
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||||
|
html += '</div>';
|
||||||
|
if(content) content.innerHTML = html;
|
||||||
|
var bs2 = new bootstrap.Modal(modal);
|
||||||
|
bs2.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
}catch(err){ console.error('markup link handler error', err); }
|
||||||
|
}, false);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
class="mt-2">
|
class="mt-2">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if field_kind == "textarea" %}
|
{% if field_kind == "textarea" %}
|
||||||
|
<div class="markup-toolbar-placeholder"></div>
|
||||||
<textarea class="form-control" name="value" rows="4">{{ value|default_if_none:"" }}</textarea>
|
<textarea class="form-control" name="value" rows="4">{{ value|default_if_none:"" }}</textarea>
|
||||||
{% elif field_kind == "select" %}
|
{% elif field_kind == "select" %}
|
||||||
<select class="form-select" name="value" {% if multiple %}multiple{% endif %}>
|
<select class="form-select" name="value" {% if multiple %}multiple{% endif %}>
|
||||||
@@ -71,7 +72,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% if field_kind == "textarea" %}
|
{% if field_kind == "textarea" %}
|
||||||
{% if display_value %}
|
{% if display_value %}
|
||||||
<div class="pre-whitespace{% if show_label %} mt-1{% endif %}">{{ display_value|linebreaks }}</div>
|
{% load markup_links %}
|
||||||
|
<div class="pre-whitespace{% if show_label %} mt-1{% endif %}">{{ display_value|render_markup }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="{% if show_label %}mt-1 {% endif %}text-muted">Not set</div>
|
<div class="{% if show_label %}mt-1 {% endif %}text-muted">Not set</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
# Syntax: [[type:arg1[:arg2...][|visible text]]]
|
||||||
|
PATTERN = re.compile(r"\[\[([a-zA-Z_]+):([^\]\|]+)(?:\|([^\]]+))?\]\]")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(is_safe=True)
|
||||||
|
def render_markup(value):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def repl(match):
|
||||||
|
typ = match.group(1)
|
||||||
|
args_raw = match.group(2)
|
||||||
|
text_override = match.group(3)
|
||||||
|
args = [a for a in args_raw.split(":") if a != ""]
|
||||||
|
|
||||||
|
# Default display text
|
||||||
|
display = text_override or (" ".join([typ.capitalize()] + args))
|
||||||
|
|
||||||
|
# Build href when possible
|
||||||
|
href = "#"
|
||||||
|
data_args = json.dumps(args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if typ == "case":
|
||||||
|
# args[0] expected to be case PK
|
||||||
|
case_id = int(args[0])
|
||||||
|
href = reverse("atlas:collection_detail", args=[case_id])
|
||||||
|
elif typ == "finding":
|
||||||
|
# finding view not implemented here; provide data attrs for JS
|
||||||
|
href = "#"
|
||||||
|
elif typ == "displayset":
|
||||||
|
href = "#"
|
||||||
|
except Exception:
|
||||||
|
href = "#"
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<a href="{}" class="markup-link" data-type="{}" data-args="{}">{}</a>',
|
||||||
|
href,
|
||||||
|
typ,
|
||||||
|
data_args,
|
||||||
|
display,
|
||||||
|
)
|
||||||
|
|
||||||
|
replaced = PATTERN.sub(repl, str(value))
|
||||||
|
# Preserve newlines
|
||||||
|
replaced = replaced.replace("\n", "<br>")
|
||||||
|
return mark_safe(replaced)
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
<script src="{% static 'js/tex-mml-chtml.js' %}"></script>
|
<script src="{% static 'js/tex-mml-chtml.js' %}"></script>
|
||||||
<script src="{% static 'js/dexie.js' %}"></script>
|
<script src="{% static 'js/dexie.js' %}"></script>
|
||||||
<script src="{% static 'js/html5sortable.min.js' %}"></script>
|
<script src="{% static 'js/html5sortable.min.js' %}"></script>
|
||||||
|
<script src="{% static 'atlas/js/markup_inserter.js' %}"></script>
|
||||||
{% comment %} <script src="{% static 'tesseract.min.js' %}"></script> {% endcomment %}
|
{% comment %} <script src="{% static 'tesseract.min.js' %}"></script> {% endcomment %}
|
||||||
<script src='https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js'></script>
|
<script src='https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js'></script>
|
||||||
<script src="{% static 'js/toastr.min.js' %}"></script>
|
<script src="{% static 'js/toastr.min.js' %}"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user