Chat: Add markdown preview toggle with client-side rendering and user preference

- Add renderMarkdownPreview() function to app.js matching server-side Go implementation
- Support headers (#, ##, ###), bold (**text**), italic (*text*), code (`code`), code blocks (```lang), lists (* item), and @mentions
- Add Edit/Preview tab UI above chat textarea with active tab highlighting
- Preview updates in real-time with 300ms debounce while typing
- Respect user's markdown_preview_default preference from settings (edit or preview)
- Pass user preferences to chat template via ChatHandler
- Add markdown tab and preview container styles matching beige/blue/pink theme
- Preview shows formatted HTML identical to actual chat messages
- Support dark mode with appropriate color adjustments for tabs and preview
- Tabs use cyan accent for active state, orange for inactive hover

User can switch between editing markdown and seeing live preview of formatted output.
jocadbz
Joca 2026-01-15 23:26:37 -03:00
parent e76049a353
commit ffe9f30c0a
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
3 changed files with 311 additions and 9 deletions

View File

@ -223,12 +223,21 @@ func ChatHandler(app *App) http.HandlerFunc {
} }
allUsernamesJSON, _ := json.Marshal(allUsernames) allUsernamesJSON, _ := json.Marshal(allUsernames)
// Get user preferences for markdown preview default
prefs, err := models.GetUserPreferences(app.DB, userID)
if err != nil {
log.Printf("Error fetching user preferences: %v", err)
// Create default if not found
prefs, _ = models.CreateDefaultPreferences(app.DB, userID)
}
data := struct { data := struct {
PageData PageData
Board models.Board Board models.Board
Messages []models.ChatMessage Messages []models.ChatMessage
AllUsernames template.JS AllUsernames template.JS
CurrentUsername string CurrentUsername string
MarkdownPreviewDefault string
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR Chat - " + board.Name, Title: "ThreadR Chat - " + board.Name,
@ -239,10 +248,11 @@ func ChatHandler(app *App) http.HandlerFunc {
StaticPath: app.Config.ThreadrDir + "/static", StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path, CurrentURL: r.URL.Path,
}, },
Board: *board, Board: *board,
Messages: messages, Messages: messages,
AllUsernames: template.JS(allUsernamesJSON), AllUsernames: template.JS(allUsernamesJSON),
CurrentUsername: currentUsername, CurrentUsername: currentUsername,
MarkdownPreviewDefault: prefs.MarkdownPreviewDefault,
} }
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
log.Printf("Error executing template in ChatHandler: %v", err) log.Printf("Error executing template in ChatHandler: %v", err)

View File

@ -371,3 +371,118 @@ function showDraftIndicator(content, timestamp, onRestore, onDiscard) {
return indicator; return indicator;
} }
// ============================================
// Markdown Preview Functions
// ============================================
// Escape HTML to prevent XSS
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Process inline markdown (bold, italic, code, mentions)
function processInlineMarkdown(line) {
// Inline code first (to avoid processing markdown inside code)
line = line.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold: **text** or __text__
line = line.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
line = line.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_
line = line.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
line = line.replace(/_([^_]+)_/g, '<em>$1</em>');
// Mentions: @username
line = line.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
return line;
}
// Render markdown to HTML (matching Go implementation)
function renderMarkdownPreview(content) {
let html = '';
// Extract code blocks first and replace with placeholders
const codeBlocks = [];
let codeBlockCounter = 0;
content = content.replace(/```(\w*)\n([\s\S]*?)\n```/g, (match, lang, code) => {
const escapedCode = escapeHTML(code);
let renderedBlock;
if (lang) {
renderedBlock = `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
} else {
renderedBlock = `<pre><code>${escapedCode}</code></pre>`;
}
const placeholder = `<!--CODEBLOCK_${codeBlockCounter}-->`;
codeBlocks[codeBlockCounter] = renderedBlock;
codeBlockCounter++;
return placeholder;
});
// Process lines
const lines = content.split('\n');
let inList = false;
for (const line of lines) {
const trimmedLine = line.trim();
// Headers
if (trimmedLine.startsWith('### ')) {
if (inList) { html += '</ul>\n'; inList = false; }
html += '<h3>' + processInlineMarkdown(trimmedLine.substring(4)) + '</h3>\n';
continue;
} else if (trimmedLine.startsWith('## ')) {
if (inList) { html += '</ul>\n'; inList = false; }
html += '<h2>' + processInlineMarkdown(trimmedLine.substring(3)) + '</h2>\n';
continue;
} else if (trimmedLine.startsWith('# ')) {
if (inList) { html += '</ul>\n'; inList = false; }
html += '<h1>' + processInlineMarkdown(trimmedLine.substring(2)) + '</h1>\n';
continue;
}
// Lists
if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
if (!inList) {
html += '<ul>\n';
inList = true;
}
const listContent = trimmedLine.substring(2);
html += '<li>' + processInlineMarkdown(listContent) + '</li>\n';
continue;
}
// Close list if we're not in a list item anymore
if (inList) {
html += '</ul>\n';
inList = false;
}
// Regular paragraphs
if (trimmedLine !== '') {
html += '<p>' + processInlineMarkdown(trimmedLine) + '</p>\n';
} else {
html += '\n';
}
}
// Close list if still open
if (inList) {
html += '</ul>\n';
}
// Replace code block placeholders
codeBlocks.forEach((block, index) => {
html = html.replace(`<!--CODEBLOCK_${index}-->`, block);
});
// Clean up excessive newlines
html = html.replace(/\n{3,}/g, '\n\n');
return html;
}

View File

@ -247,6 +247,99 @@
padding: 6px 12px; padding: 6px 12px;
font-size: 0.9em; font-size: 0.9em;
} }
.markdown-tabs {
display: flex;
gap: 0;
margin-bottom: 0;
border-bottom: 1px solid #001858;
}
.markdown-tab {
flex: 1;
padding: 8px 16px;
border: none;
background-color: #f3d2c1;
color: #001858;
font-family: monospace;
font-size: 0.9em;
cursor: pointer;
border-right: 1px solid #001858;
transition: background-color 0.2s ease;
}
.markdown-tab:last-child {
border-right: none;
}
.markdown-tab:hover {
background-color: #fef6e4;
}
.markdown-tab.active {
background-color: #8bd3dd;
color: #001858;
font-weight: bold;
}
.markdown-content-container {
position: relative;
min-height: 50px;
}
.chat-input textarea,
.markdown-preview {
display: none;
resize: none;
height: 50px;
margin-bottom: 8px;
font-size: 0.9em;
width: 100%;
box-sizing: border-box;
}
.chat-input textarea.markdown-visible,
.markdown-preview.markdown-visible {
display: block;
}
.markdown-preview {
border: 1px solid #001858;
border-radius: 5px;
padding: 8px;
background-color: #fef6e4;
color: #001858;
min-height: 50px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
}
.markdown-preview h1 {
font-size: 1.5em;
margin: 0.5em 0;
}
.markdown-preview h2 {
font-size: 1.3em;
margin: 0.5em 0;
}
.markdown-preview h3 {
font-size: 1.1em;
margin: 0.5em 0;
}
.markdown-preview p {
margin: 0.5em 0;
}
.markdown-preview ul {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-preview code {
background-color: #f3d2c1;
padding: 2px 4px;
border-radius: 3px;
}
.markdown-preview pre {
background-color: #f3d2c1;
padding: 8px;
border-radius: 5px;
overflow-x: auto;
margin: 0.5em 0;
}
.markdown-preview pre code {
background-color: transparent;
padding: 0;
}
.post-actions { .post-actions {
position: absolute; position: absolute;
top: 5px; top: 5px;
@ -410,6 +503,29 @@
border-color: #f582ae; border-color: #f582ae;
} }
} }
.markdown-tab {
background-color: #555;
color: #fef6e4;
border-color: #fef6e4;
}
.markdown-tab:hover {
background-color: #666;
}
.markdown-tab.active {
background-color: #8bd3dd;
color: #001858;
}
.markdown-preview {
background-color: #333;
color: #fef6e4;
border-color: #fef6e4;
}
.markdown-preview code {
background-color: #555;
}
.markdown-preview pre {
background-color: #555;
}
} }
</style> </style>
</head> </head>
@ -466,7 +582,14 @@
<span id="reply-username">Replying to </span> <span id="reply-username">Replying to </span>
<button onclick="cancelReply()">X</button> <button onclick="cancelReply()">X</button>
</div> </div>
<textarea id="chat-input-text" placeholder="Type a message..."></textarea> <div class="markdown-tabs">
<button class="markdown-tab active" data-tab="edit" onclick="switchMarkdownTab('edit')">Edit</button>
<button class="markdown-tab" data-tab="preview" onclick="switchMarkdownTab('preview')">Preview</button>
</div>
<div class="markdown-content-container">
<textarea id="chat-input-text" placeholder="Type a message..." class="markdown-visible"></textarea>
<div id="chat-preview" class="markdown-preview"></div>
</div>
<button onclick="sendMessage()">Send</button> <button onclick="sendMessage()">Send</button>
</div> </div>
</div> </div>
@ -829,6 +952,57 @@
} }
}); });
// Markdown preview functionality
let currentTab = '{{.MarkdownPreviewDefault}}' || 'edit';
let previewUpdateTimeout;
function switchMarkdownTab(tab) {
currentTab = tab;
const textarea = document.getElementById('chat-input-text');
const preview = document.getElementById('chat-preview');
const tabs = document.querySelectorAll('.markdown-tab');
// Update tab styling
tabs.forEach(t => {
if (t.dataset.tab === tab) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
// Show/hide content
if (tab === 'edit') {
textarea.classList.add('markdown-visible');
preview.classList.remove('markdown-visible');
textarea.focus();
} else {
textarea.classList.remove('markdown-visible');
preview.classList.add('markdown-visible');
updatePreview();
}
}
function updatePreview() {
const textarea = document.getElementById('chat-input-text');
const preview = document.getElementById('chat-preview');
const content = textarea.value;
if (content.trim() === '') {
preview.innerHTML = '<p style="opacity: 0.5; font-style: italic;">Nothing to preview. Type some markdown in the Edit tab.</p>';
} else {
preview.innerHTML = renderMarkdownPreview(content);
}
}
// Update preview on input (debounced)
document.getElementById('chat-input-text').addEventListener('input', () => {
if (currentTab === 'preview') {
clearTimeout(previewUpdateTimeout);
previewUpdateTimeout = setTimeout(updatePreview, 300);
}
});
window.onload = function() { window.onload = function() {
connectWebSocket(); connectWebSocket();
const messagesContainer = document.getElementById('chat-messages'); const messagesContainer = document.getElementById('chat-messages');
@ -906,6 +1080,9 @@
originalSendMessage(); originalSendMessage();
}; };
// Initialize markdown preview tab based on user preference
switchMarkdownTab(currentTab);
// Initial check for scroll position // Initial check for scroll position
checkScrollPosition(); checkScrollPosition();
}; };