Fluent Emoji 3D: Unicode-Emojis durch Microsoft Fluent Emoji ersetzen

- emoji.js: Emoji-Replacement-Engine mit umfassendem Unicode 15.0 Regex
  - String-basiert (replaceEmojisWithFluent) in renderMarkdown()
  - DOM-basiert (parseFluentEmojis) als Post-Processing via TreeWalker
  - CDN: @lobehub/fluent-emoji-3d@1.1.0 via jsdelivr (WebP/3D)
  - Intelligenter Fallback: fe0f-Varianten, dann Original-Unicode
  - Fail-Cache verhindert wiederholte Requests für fehlende Emojis
- User-Nachrichten werden jetzt als Markdown gerendert (renderMarkdown)
  - [Datei: ...] Refs: Platzhalter-Mechanismus vor Markdown-Parsing
  - Reply-Quotes bleiben durch Wrapper-Div erhalten
- parseFluentEmojis an allen Render-Stellen integriert:
  addAssistantBubble, endStreamBubble, _renderUserTextContent,
  _renderSystemPromptBlocks, history._renderMessagesIntoFragment,
  history.loadLatestArchive
- CSS: .fluent-emoji Klasse (1.2em, inline, scale 1.25)
- CSS: white-space:pre-wrap nur noch für .msg:not(.markdown)
This commit is contained in:
Meleki committed 2026-03-03 05:53:31 +01:00
1 parent e2c887aee6
commit 5d132585d5
6 files changed
+156 -33

No files matched your search

+4 -1
View File
@@ -85,10 +85,13 @@
}
.msg {
white-space: pre-wrap;
word-break: break-word;
}
.msg:not(.markdown) {
white-space: pre-wrap;
}
.msg.user {
align-self: flex-end;
background: var(--f3-bg);
+12
View File
@@ -133,3 +133,15 @@
.markdown input[type="checkbox"] {
margin-right: .5em;
}
/* Fluent Emoji */
img.fluent-emoji {
display: inline-block;
height: 1.2em;
width: 1.2em;
vertical-align: -0.2em;
object-fit: contain;
margin: 0 .1em;
scale: 1.25;
}
+1
View File
@@ -339,6 +339,7 @@
<script src="js/lib/cm-mode-yaml.min.js"></script>
<script src="js/lib/easymde.min.js"></script>
<script src="js/tooltip.js"></script>
<script src="js/emoji.js"></script>
<script src="js/ui.js"></script>
<script src="js/api.js"></script>
<script src="js/session.js"></script>
+100
View File
@@ -0,0 +1,100 @@
// ── Fluent Emoji Replacement ──
// Replaces Unicode emojis in rendered Markdown HTML with Fluent Emoji 3D images.
// Uses lobehub's CDN-hosted Microsoft Fluent Emoji 3D set (WebP format).
var FLUENT_EMOJI_CDN = 'https://cdn.jsdelivr.net/npm/@lobehub/fluent-emoji-3d@1.1.0/assets/';
var _emojiFailCache = {}; // Track permanently failed emoji codepoints
// Comprehensive emoji regex (from AdvenaHQ/fluent-emoji, MIT licensed)
// Covers ZWJ sequences, flags, keycaps, skin tones, text-style emojis, etc.
var _emojiRegex = /(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfffLine truncated
function _emojiToCodepoints(emoji) {
var codepoints = [];
for (var i = 0; i < emoji.length; i++) {
var cp = emoji.codePointAt(i);
if (cp > 0xFFFF) i++; // Skip low surrogate
codepoints.push(cp.toString(16));
}
return codepoints.join('-');
}
/**
* Global error handler for fluent emoji images.
* Tries alternate codepoint variants (with/without fe0f) before giving up.
*/
function _fluentEmojiError(img) {
var cp = img.dataset.cp;
var tried = parseInt(img.dataset.tried || '0');
if (tried === 0) {
// First failure: try toggling fe0f
var alt = null;
if (cp.indexOf('-fe0f') !== -1) {
alt = cp.replace(/-fe0f/g, '');
} else {
// Try adding fe0f after first codepoint
var parts = cp.split('-');
parts.splice(1, 0, 'fe0f');
alt = parts.join('-');
}
img.dataset.tried = '1';
img.dataset.cp = alt;
img.src = FLUENT_EMOJI_CDN + alt + '.webp';
return;
}
// All variants failed → replace with original text emoji
_emojiFailCache[cp] = true;
var text = document.createTextNode(img.alt);
img.parentNode.replaceChild(text, img);
}
function replaceEmojisWithFluent(html) {
return html.replace(_emojiRegex, function(match) {
var codepoints = _emojiToCodepoints(match);
if (_emojiFailCache[codepoints]) return match;
var url = FLUENT_EMOJI_CDN + codepoints + '.webp';
return '<img class="fluent-emoji" src="' + url + '" alt="' + match + '" data-cp="' + codepoints + '" data-tried="0" loading="lazy" draggable="false" onerror="_fluentEmojiError(this)">';
});
}
/**
* DOM-based emoji replacement.
* Walks all text nodes inside the given element and replaces Unicode emojis
* with Fluent Emoji <img> tags. Useful as a post-processing step after
* innerHTML has been set (catches emojis that the string-based replace missed).
*/
function parseFluentEmojis(container) {
if (!container) return;
var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
var textNodes = [];
var node;
while ((node = walker.nextNode())) {
if (_emojiRegex.test(node.nodeValue)) {
textNodes.push(node);
}
// Reset regex lastIndex (global flag not set, but safety)
_emojiRegex.lastIndex = 0;
}
for (var i = 0; i < textNodes.length; i++) {
var tn = textNodes[i];
// Skip text nodes inside <code>, <pre>, <script>, <style>
var parent = tn.parentNode;
if (parent && /^(CODE|PRE|SCRIPT|STYLE)$/i.test(parent.tagName)) continue;
var replaced = tn.nodeValue.replace(_emojiRegex, function(match) {
var codepoints = _emojiToCodepoints(match);
if (_emojiFailCache[codepoints]) return match;
var url = FLUENT_EMOJI_CDN + codepoints + '.webp';
return '<img class="fluent-emoji" src="' + url + '" alt="' + match + '" data-cp="' + codepoints + '" data-tried="0" loading="lazy" draggable="false" onerror="_fluentEmojiError(this)">';
});
if (replaced !== tn.nodeValue) {
var span = document.createElement('span');
span.innerHTML = replaced;
while (span.firstChild) {
parent.insertBefore(span.firstChild, tn);
}
parent.removeChild(tn);
}
}
}
+4 -2
View File
@@ -145,7 +145,7 @@ function _renderMessagesIntoFragment(fragment, msgs) {
switch (msg.type) {
case 'text':
if (msg.role === 'user') {
div.className = 'msg user' + (msg.isMarked ? ' marked' : '');
div.className = 'msg user markdown' + (msg.isMarked ? ' marked' : '');
div.dataset.role = 'user';
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
@@ -171,6 +171,7 @@ function _renderMessagesIntoFragment(fragment, msgs) {
div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addReplyButton(div);
_addMarkButton(div, msg.id, msg.isMarked);
fragment.appendChild(div);
@@ -295,11 +296,12 @@ async function loadLatestArchive() {
switch (msg.type) {
case 'text':
if (msg.role === 'user') {
div.className = 'msg user archived';
div.className = 'msg user markdown archived';
renderUserContent(div, msg.content);
} else {
div.className = 'msg assistant markdown archived';
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
}
if (msg.role === 'user' && msg.attachmentCount > 0) {
let names = [];
+35 -30
View File
@@ -43,6 +43,8 @@ function renderMarkdown(text) {
+ '<img class="krea-thumb" src="' + thumbUrl + '" alt="' + safeFn + '" loading="lazy">'
+ '</span>';
});
// Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken)
html = replaceEmojisWithFluent(html);
// Code-Blöcke wiederherstellen
html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) {
return codeBlocks[parseInt(idx)];
@@ -200,47 +202,47 @@ function renderUserContent(container, text) {
}
function _renderUserTextContent(container, raw) {
// [Datei: path] oder [Datei: path:1-10] erkennen
// [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing)
const pattern = /\[Datei: ([^\]]+)\]/g;
let last = 0;
let match;
const frag = document.createDocumentFragment();
const fileRefs = [];
const mdText = raw.replace(pattern, function(m, ref) {
fileRefs.push(ref);
return '%%FILEREF' + (fileRefs.length - 1) + '%%';
});
while ((match = pattern.exec(raw)) !== null) {
// Text vor dem Tag
if (match.index > last) {
frag.appendChild(document.createTextNode(raw.slice(last, match.index)));
}
// Markdown rendern
let html = renderMarkdown(mdText);
const ref = match[1]; // z.B. "src/foo.cs:10-25"
// Pfad ohne Zeilenangabe extrahieren
// Platzhalter durch File-Ref-Chips ersetzen (als HTML)
html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) {
const ref = fileRefs[parseInt(idx)];
const filePath = ref.replace(/:\d+(-\d+)?$/, '');
const chip = document.createElement('span');
chip.className = 'file-ref-chip';
// Schöne Darstellung: Icon + Kurzpfad
const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath;
const lineInfo = ref.replace(filePath, '').trim(); // z.B. ":10-25"
chip.innerHTML = '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo);
chip.title = ref;
chip.style.cursor = 'pointer';
chip.addEventListener('click', () => {
openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: filePath });
const lineInfo = ref.replace(filePath, '').trim();
return '<span class="file-ref-chip" data-filepath="' + esc(filePath) + '" title="' + esc(ref) + '" style="cursor:pointer">'
+ '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo) + '</span>';
});
// In ein Wrapper-Div rendern (damit vorherige Elemente wie Reply-Quotes erhalten bleiben)
const wrap = document.createElement('div');
wrap.className = 'user-text-content';
wrap.innerHTML = html;
container.appendChild(wrap);
// Click-Handler für File-Ref-Chips nachträglich binden
wrap.querySelectorAll('.file-ref-chip[data-filepath]').forEach(function(chip) {
chip.addEventListener('click', function() {
openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: chip.dataset.filepath });
});
frag.appendChild(chip);
last = match.index + match[0].length;
}
});
// Resttext
if (last < raw.length) {
frag.appendChild(document.createTextNode(raw.slice(last)));
}
container.appendChild(frag);
// Fluent Emoji DOM-Parsing
parseFluentEmojis(wrap);
}
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false) {
const div = document.createElement('div');
div.className = 'msg user' + (isMarked ? ' marked' : '');
div.className = 'msg user markdown' + (isMarked ? ' marked' : '');
div.dataset.role = 'user';
div.dataset.timestamp = timestamp || new Date().toISOString();
if (messageId) div.dataset.messageId = messageId;
@@ -259,6 +261,7 @@ function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = f
div.dataset.timestamp = timestamp || new Date().toISOString();
if (messageId) div.dataset.messageId = messageId;
div.innerHTML = renderMarkdown(text);
parseFluentEmojis(div);
wrapKreaGalleries(div);
_addReplyButton(div);
_addMarkButton(div, messageId, isMarked);
@@ -324,6 +327,7 @@ function endStreamBubble() {
if (!_streamText.trim()) _streamDiv.remove();
else {
_streamDiv.innerHTML = renderMarkdown(_streamText);
parseFluentEmojis(_streamDiv);
wrapKreaGalleries(_streamDiv);
}
}
@@ -645,6 +649,7 @@ function _renderSystemPromptBlocks(container, blocks) {
var content = document.createElement('div');
content.className = 'sp-block-content markdown';
content.innerHTML = renderMarkdown(block.content);
parseFluentEmojis(content);
wrapper.appendChild(content);
container.appendChild(wrapper);