formatted-message.svelte RawHistoryFolder
1<script lang="ts">
2 import type { EnrichedMessage } from '../types/chat';
3 import type { TextStyle } from '../types/text-formatting';
4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6 import { highlightMentionsPlain } from '../utils/mention';
7 import { replaceSmileys } from '$lib/aim/smileys';
8
9 // Props
10 let { message, allowFormatting = true } = $props<{
11 message: EnrichedMessage;
12 allowFormatting?: boolean;
13 }>();
14
15 // State
16 let formattedContent = $state('');
17 let isProcessing = $state(false);
18 let hasError = $state(false);
19
20 // Process message formatting
21 async function processMessage() {
22 if (!allowFormatting || !message.hasFormatting) {
23 // Still apply mention highlighting to plain text messages
24 formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
25 return;
26 }
27
28 isProcessing = true;
29 hasError = false;
30
31 try {
32 // Parse style data if available
33 let style: TextStyle = DEFAULT_TEXT_STYLE;
34 if (message.styleData) {
35 try {
36 style = JSON.parse(message.styleData);
37 } catch (parseError) {
38 console.warn('Failed to parse message style data:', parseError);
39 }
40 }
41
42 // Check if this is a gradient message
43 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
44 // Use character-by-character gradient for better compatibility
45 formattedContent = createGradientText(
46 message.content,
47 style.gradient,
48 'gradient-text-static',
49 style
50 );
51 return;
52 }
53
54 // Regular formatted text (no gradient)
55 const styleWithoutGradient = { ...style, gradient: undefined };
56 formattedContent = replaceSmileys(
57 await formatText(message.content, styleWithoutGradient, allowFormatting)
58 );
59 } catch (error) {
60 console.error('Message formatting error:', error);
61 hasError = true;
62 formattedContent = escapeHtml(message.content);
63 } finally {
64 isProcessing = false;
65 }
66 }
67
68 // Process message when it changes
69 $effect(() => {
70 processMessage();
71 });
72
73 // Generate inline styles for the message
74 function getMessageStyles(): string {
75 if (!message.hasFormatting || !message.styleData) {
76 return '';
77 }
78
79 try {
80 const style: TextStyle = JSON.parse(message.styleData);
81 // For gradient messages, we still need some styles but not font-family (use class instead)
82 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
83 // Return only non-color and non-font-family styles for gradient messages
84 const styles: string[] = [];
85 styles.push(`font-size: ${style.fontSize}px`);
86 if (style.bold) styles.push(`font-weight: bold`);
87 if (style.italic) styles.push(`font-style: italic`);
88 const decorations: string[] = [];
89 if (style.underline) decorations.push('underline');
90 if (style.strikethrough) decorations.push('line-through');
91 if (decorations.length > 0) {
92 styles.push(`text-decoration: ${decorations.join(' ')}`);
93 }
94 return styles.join('; ');
95 }
96 // For non-gradient messages, exclude font-family (use class instead)
97 const styles: string[] = [];
98 styles.push(`font-size: ${style.fontSize}px`);
99 if (style.color) styles.push(`color: ${style.color}`);
100 if (style.bold) styles.push(`font-weight: bold`);
101 if (style.italic) styles.push(`font-style: italic`);
102 const decorations: string[] = [];
103 if (style.underline) decorations.push('underline');
104 if (style.strikethrough) decorations.push('line-through');
105 if (decorations.length > 0) {
106 styles.push(`text-decoration: ${decorations.join(' ')}`);
107 }
108 return styles.join('; ');
109 } catch (error) {
110 console.warn('Failed to generate message styles:', error);
111 return '';
112 }
113 }
114
115 // Get CSS classes for the message
116 function getMessageClasses(): string {
117 const classes = ['formatted-message'];
118
119 if (!message.hasFormatting || !message.styleData) {
120 return classes.join(' ');
121 }
122
123 try {
124 const style: TextStyle = JSON.parse(message.styleData);
125
126 // Add font family class
127 classes.push(`retro-font-${style.fontFamily}`);
128
129 // Add style classes
130 if (style.bold) classes.push('style-bold');
131 if (style.italic) classes.push('style-italic');
132 if (style.underline) classes.push('style-underline');
133 if (style.strikethrough) classes.push('style-strikethrough');
134
135 // Add gradient class if applicable
136 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
137 classes.push('gradient-text-static');
138 }
139 } catch (error) {
140 console.warn('Failed to parse message style for classes:', error);
141 }
142
143 return classes.join(' ');
144 }
145</script>
146
147{#if isProcessing}
148 <span class={getMessageClasses()} style={getMessageStyles()}>
149 <span class="processing">...</span>
150 </span>
151{:else if hasError}
152 <span class={getMessageClasses()} style={getMessageStyles()}>
153 <span class="error" title="Formatting error - showing plain text">{message.content}</span>
154 </span>
155{:else}
156 <!-- For gradient messages, render without wrapper to avoid conflicts -->
157 {#if message.hasFormatting && message.styleData}
158 {@const parsedStyle = (() => {
159 try {
160 return JSON.parse(message.styleData);
161 } catch {
162 return null;
163 }
164 })()}
165 {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1}
166 <!-- Gradient messages are already wrapped properly -->
167 <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by createGradientText -->
168 {@html formattedContent}
169 {:else}
170 <!-- Regular formatted messages -->
171 <span class={getMessageClasses()} style={getMessageStyles()}>
172 <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by formatText -->
173 {@html formattedContent}
174 </span>
175 {/if}
176 {:else}
177 <!-- Plain messages -->
178 <span class={getMessageClasses()}>
179 <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by escapeHtml -->
180 {@html formattedContent}
181 </span>
182 {/if}
183{/if}
184
185<style>
186 .formatted-message :global(.aim-smiley),
187 :global(.aim-smiley) {
188 width: 19px;
189 height: 19px;
190 vertical-align: -4px;
191 image-rendering: pixelated;
192 }
193 .processing {
194 color: #666;
195 font-style: italic;
196 animation: pulse 1s infinite;
197 }
198
199 .error {
200 color: #cc0000;
201 border-bottom: 1px dotted #cc0000;
202 }
203
204 @keyframes pulse {
205 0%,
206 100% {
207 opacity: 1;
208 }
209 50% {
210 opacity: 0.5;
211 }
212 }
213
214 /* Ensure formatted messages don't break layout */
215 :global(.formatted-message) {
216 word-break: break-word;
217 overflow-wrap: break-word;
218 hyphens: auto;
219 }
220
221 /* Improve gradient text readability */
222 :global(.formatted-message.gradient-text-static) {
223 font-weight: bold;
224 }
225
226 /* Ensure gradient spans are visible and preserve their colors */
227 :global(.gradient-text-static .gradient-char) {
228 display: inline;
229 opacity: 1 !important;
230 margin: 0 !important;
231 padding: 0 !important;
232 letter-spacing: 0 !important;
233 -webkit-text-fill-color: initial !important;
234 }
235
236 /* Don't override inline styles for gradient chars - removed to let inline styles work */
237
238 /* Remove any blur or gray effects */
239 :global(.formatted-message) {
240 opacity: 1 !important;
241 filter: none !important;
242 }
243
244 /* Don't inherit color for gradient text spans */
245 :global(.formatted-message:not(.gradient-text-static)) {
246 color: inherit;
247 }
248
249 /* Mention token styling - inherits message color/font, just adds bold */
250 :global(.mention-token) {
251 color: inherit;
252 font-weight: bold;
253 }
254
255 /* Ensure accessibility for screen readers */
256 :global(.formatted-message[aria-label]) {
257 position: relative;
258 }
259
260 /* Performance optimization for complex messages */
261 :global(.formatted-message) {
262 contain: layout style;
263 }
264</style>