feat(aim): classic AIM yellow smileys with picker in rooms and IMs
Typed codes (:-) ;-) :-P O:-) ...) render as the original AIM Yellow Smiley GIFs from static/smileys (local-only, gitignored), falling back to Unicode emoji alt text. The formatting toolbar gains an AIM-style smiley picker that inserts the code.
- commit
- da04fc0c6c7228747df635b801c574df2c488d13
- author
- Kit · · 2026-09-27 13:53 UTC
- parent
- a2d83fb
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
7 files · +192 -4
M .gitignore +3 -0
| @@ -34,3 +34,6 @@ docs/plans/ | |||
| 34 | 34 | # Local audio samples (copyrighted, never committed) | |
| 35 | 35 | static/sounds/* | |
| 36 | 36 | !static/sounds/README.md | |
| 37 | + | # AIM smiley GIFs (AOL artwork, never committed) | |
| 38 | + | static/smileys/* | |
| 39 | + | !static/smileys/README.md | |
| @@ -34,3 +34,6 @@ docs/plans/ | |||||
| 34 | # Local audio samples (copyrighted, never committed) | 34 | # Local audio samples (copyrighted, never committed) | ||
| 35 | static/sounds/* | 35 | static/sounds/* | ||
| 36 | !static/sounds/README.md | 36 | !static/sounds/README.md | ||
| 37 | + | # AIM smiley GIFs (AOL artwork, never committed) | |||
| 38 | + | static/smileys/* | |||
| 39 | + | !static/smileys/README.md | |||
A src/lib/aim/smileys.ts +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | /** | |
| 2 | + | * AIM "Yellow Smiley" set (AIM 4.x/5.x). Typed codes become the original | |
| 3 | + | * 19×19 GIFs from /static/smileys (local-only, AOL artwork). When a GIF is | |
| 4 | + | * missing the browser shows the img alt text, which is the Unicode emoji. | |
| 5 | + | */ | |
| 6 | + | export interface Smiley { | |
| 7 | + | file: string; | |
| 8 | + | codes: string[]; | |
| 9 | + | label: string; | |
| 10 | + | emoji: string; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export const SMILEYS: Smiley[] = [ | |
| 14 | + | { file: '01', codes: [':-)', ':)'], label: 'Content', emoji: '🙂' }, | |
| 15 | + | { file: '02', codes: [';-)', ';)'], label: 'Clin d’œil', emoji: '😉' }, | |
| 16 | + | { file: '03', codes: [':-(', ':('], label: 'Triste', emoji: '🙁' }, | |
| 17 | + | { file: '04', codes: [':-P', ':-p', ':P', ':p'], label: 'Langue', emoji: '😛' }, | |
| 18 | + | { file: '05', codes: ['=-O', '=-o'], label: 'Surpris', emoji: '😮' }, | |
| 19 | + | { file: '06', codes: [':-*'], label: 'Bisou', emoji: '😘' }, | |
| 20 | + | { file: '07', codes: ['>:o'], label: 'Crie', emoji: '😠' }, | |
| 21 | + | { file: '08', codes: [':-D', ':D'], label: 'Rire', emoji: '😁' }, | |
| 22 | + | { file: '09', codes: [':-$'], label: 'Radin', emoji: '🤑' }, | |
| 23 | + | { file: '10', codes: [':-!'], label: 'Gaffe', emoji: '😬' }, | |
| 24 | + | { file: '11', codes: [':-['], label: 'Gêné', emoji: '😳' }, | |
| 25 | + | { file: '12', codes: ['O:-)'], label: 'Innocent', emoji: '😇' }, | |
| 26 | + | { file: '13', codes: [':-\\'], label: 'Indécis', emoji: '😕' }, | |
| 27 | + | { file: '14', codes: [":'("], label: 'Pleure', emoji: '😢' }, | |
| 28 | + | { file: '15', codes: [':-X', ':-x'], label: 'Bouche cousue', emoji: '🤐' }, | |
| 29 | + | { file: '16', codes: ['8-)'], label: 'Cool', emoji: '😎' } | |
| 30 | + | ]; | |
| 31 | + | ||
| 32 | + | const escape = (text: string) => | |
| 33 | + | text | |
| 34 | + | .replace(/&/g, '&') | |
| 35 | + | .replace(/</g, '<') | |
| 36 | + | .replace(/>/g, '>') | |
| 37 | + | .replace(/"/g, '"') | |
| 38 | + | .replace(/'/g, '''); | |
| 39 | + | ||
| 40 | + | // Codes are matched on already-escaped HTML, so match their escaped form. | |
| 41 | + | const byEscapedCode = new Map<string, Smiley>(); | |
| 42 | + | for (const smiley of SMILEYS) | |
| 43 | + | for (const code of smiley.codes) byEscapedCode.set(escape(code), smiley); | |
| 44 | + | ||
| 45 | + | const pattern = new RegExp( | |
| 46 | + | // Longest first so "O:-)" wins over ":-)"; codes must not touch letters/digits. | |
| 47 | + | `(^|[^\\w])(${[...byEscapedCode.keys()] | |
| 48 | + | .sort((a, b) => b.length - a.length) | |
| 49 | + | .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) | |
| 50 | + | .join('|')})(?=$|[^\\w])`, | |
| 51 | + | 'g' | |
| 52 | + | ); | |
| 53 | + | ||
| 54 | + | export function smileyImg(smiley: Smiley) { | |
| 55 | + | return `<img class="aim-smiley" src="/smileys/${smiley.file}.gif" alt="${smiley.emoji}" title="${escape(smiley.codes[0])}" width="19" height="19" draggable="false">`; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * Replace smiley codes in escaped HTML text. Only text between tags is | |
| 60 | + | * touched so attributes (style="color: …") are never rewritten. | |
| 61 | + | */ | |
| 62 | + | export function replaceSmileys(html: string): string { | |
| 63 | + | return html | |
| 64 | + | .split(/(<[^>]*>)/) | |
| 65 | + | .map((part) => | |
| 66 | + | part.startsWith('<') | |
| 67 | + | ? part | |
| 68 | + | : part.replace(pattern, (_, before: string, code: string) => { | |
| 69 | + | const smiley = byEscapedCode.get(code); | |
| 70 | + | return smiley ? before + smileyImg(smiley) : _; | |
| 71 | + | }) | |
| 72 | + | ) | |
| 73 | + | .join(''); | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * AIM "Yellow Smiley" set (AIM 4.x/5.x). Typed codes become the original | |||
| 3 | + | * 19×19 GIFs from /static/smileys (local-only, AOL artwork). When a GIF is | |||
| 4 | + | * missing the browser shows the img alt text, which is the Unicode emoji. | |||
| 5 | + | */ | |||
| 6 | + | export interface Smiley { | |||
| 7 | + | file: string; | |||
| 8 | + | codes: string[]; | |||
| 9 | + | label: string; | |||
| 10 | + | emoji: string; | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | export const SMILEYS: Smiley[] = [ | |||
| 14 | + | { file: '01', codes: [':-)', ':)'], label: 'Content', emoji: '🙂' }, | |||
| 15 | + | { file: '02', codes: [';-)', ';)'], label: 'Clin d’œil', emoji: '😉' }, | |||
| 16 | + | { file: '03', codes: [':-(', ':('], label: 'Triste', emoji: '🙁' }, | |||
| 17 | + | { file: '04', codes: [':-P', ':-p', ':P', ':p'], label: 'Langue', emoji: '😛' }, | |||
| 18 | + | { file: '05', codes: ['=-O', '=-o'], label: 'Surpris', emoji: '😮' }, | |||
| 19 | + | { file: '06', codes: [':-*'], label: 'Bisou', emoji: '😘' }, | |||
| 20 | + | { file: '07', codes: ['>:o'], label: 'Crie', emoji: '😠' }, | |||
| 21 | + | { file: '08', codes: [':-D', ':D'], label: 'Rire', emoji: '😁' }, | |||
| 22 | + | { file: '09', codes: [':-$'], label: 'Radin', emoji: '🤑' }, | |||
| 23 | + | { file: '10', codes: [':-!'], label: 'Gaffe', emoji: '😬' }, | |||
| 24 | + | { file: '11', codes: [':-['], label: 'Gêné', emoji: '😳' }, | |||
| 25 | + | { file: '12', codes: ['O:-)'], label: 'Innocent', emoji: '😇' }, | |||
| 26 | + | { file: '13', codes: [':-\\'], label: 'Indécis', emoji: '😕' }, | |||
| 27 | + | { file: '14', codes: [":'("], label: 'Pleure', emoji: '😢' }, | |||
| 28 | + | { file: '15', codes: [':-X', ':-x'], label: 'Bouche cousue', emoji: '🤐' }, | |||
| 29 | + | { file: '16', codes: ['8-)'], label: 'Cool', emoji: '😎' } | |||
| 30 | + | ]; | |||
| 31 | + | ||||
| 32 | + | const escape = (text: string) => | |||
| 33 | + | text | |||
| 34 | + | .replace(/&/g, '&') | |||
| 35 | + | .replace(/</g, '<') | |||
| 36 | + | .replace(/>/g, '>') | |||
| 37 | + | .replace(/"/g, '"') | |||
| 38 | + | .replace(/'/g, '''); | |||
| 39 | + | ||||
| 40 | + | // Codes are matched on already-escaped HTML, so match their escaped form. | |||
| 41 | + | const byEscapedCode = new Map<string, Smiley>(); | |||
| 42 | + | for (const smiley of SMILEYS) | |||
| 43 | + | for (const code of smiley.codes) byEscapedCode.set(escape(code), smiley); | |||
| 44 | + | ||||
| 45 | + | const pattern = new RegExp( | |||
| 46 | + | // Longest first so "O:-)" wins over ":-)"; codes must not touch letters/digits. | |||
| 47 | + | `(^|[^\\w])(${[...byEscapedCode.keys()] | |||
| 48 | + | .sort((a, b) => b.length - a.length) | |||
| 49 | + | .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) | |||
| 50 | + | .join('|')})(?=$|[^\\w])`, | |||
| 51 | + | 'g' | |||
| 52 | + | ); | |||
| 53 | + | ||||
| 54 | + | export function smileyImg(smiley: Smiley) { | |||
| 55 | + | return `<img class="aim-smiley" src="/smileys/${smiley.file}.gif" alt="${smiley.emoji}" title="${escape(smiley.codes[0])}" width="19" height="19" draggable="false">`; | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | /** | |||
| 59 | + | * Replace smiley codes in escaped HTML text. Only text between tags is | |||
| 60 | + | * touched so attributes (style="color: …") are never rewritten. | |||
| 61 | + | */ | |||
| 62 | + | export function replaceSmileys(html: string): string { | |||
| 63 | + | return html | |||
| 64 | + | .split(/(<[^>]*>)/) | |||
| 65 | + | .map((part) => | |||
| 66 | + | part.startsWith('<') | |||
| 67 | + | ? part | |||
| 68 | + | : part.replace(pattern, (_, before: string, code: string) => { | |||
| 69 | + | const smiley = byEscapedCode.get(code); | |||
| 70 | + | return smiley ? before + smileyImg(smiley) : _; | |||
| 71 | + | }) | |||
| 72 | + | ) | |||
| 73 | + | .join(''); | |||
| 74 | + | } | |||
M src/lib/components/chat-room.svelte +7 -0
| @@ -1003,6 +1003,12 @@ | |||
| 1003 | 1003 | } | |
| 1004 | 1004 | } | |
| 1005 | 1005 | ||
| 1006 | + | function insertSmiley(code: string) { | |
| 1007 | + | const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' '); | |
| 1008 | + | currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `; | |
| 1009 | + | void tick().then(() => messageInput?.focus()); | |
| 1010 | + | } | |
| 1011 | + | ||
| 1006 | 1012 | function openSignup() { | |
| 1007 | 1013 | desktop.openLogin('signup'); | |
| 1008 | 1014 | } | |
| @@ -1146,6 +1152,7 @@ | |||
| 1146 | 1152 | bind:style={currentTextStyle} | |
| 1147 | 1153 | compact={true} | |
| 1148 | 1154 | showFontSelector={true} | |
| 1155 | + | onSmiley={insertSmiley} | |
| 1149 | 1156 | /> | |
| 1150 | 1157 | </div> | |
| 1151 | 1158 | {/if} | |
| @@ -1003,6 +1003,12 @@ | |||||
| 1003 | } | 1003 | } | ||
| 1004 | } | 1004 | } | ||
| 1005 | 1005 | ||||
| 1006 | + | function insertSmiley(code: string) { | |||
| 1007 | + | const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' '); | |||
| 1008 | + | currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `; | |||
| 1009 | + | void tick().then(() => messageInput?.focus()); | |||
| 1010 | + | } | |||
| 1011 | + | ||||
| 1006 | function openSignup() { | 1012 | function openSignup() { | ||
| 1007 | desktop.openLogin('signup'); | 1013 | desktop.openLogin('signup'); | ||
| 1008 | } | 1014 | } | ||
| @@ -1146,6 +1152,7 @@ | |||||
| 1146 | bind:style={currentTextStyle} | 1152 | bind:style={currentTextStyle} | ||
| 1147 | compact={true} | 1153 | compact={true} | ||
| 1148 | showFontSelector={true} | 1154 | showFontSelector={true} | ||
| 1155 | + | onSmiley={insertSmiley} | |||
| 1149 | /> | 1156 | /> | ||
| 1150 | </div> | 1157 | </div> | ||
| 1151 | {/if} | 1158 | {/if} | ||
M src/lib/components/desktop/im-window.svelte +10 -1
| @@ -308,7 +308,16 @@ | |||
| 308 | 308 | > | |
| 309 | 309 | </div>{/if} | |
| 310 | 310 | </div> | |
| 311 | - | <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} /> | |
| 311 | + | <TextFormattingToolbar | |
| 312 | + | bind:style={textStyle} | |
| 313 | + | compact={true} | |
| 314 | + | showFontSelector={true} | |
| 315 | + | onSmiley={(code) => { | |
| 316 | + | const needsSpace = draft.length > 0 && !draft.endsWith(' '); | |
| 317 | + | draft = `${draft}${needsSpace ? ' ' : ''}${code} `; | |
| 318 | + | document.getElementById(`im-input-${roomId}`)?.focus(); | |
| 319 | + | }} | |
| 320 | + | /> | |
| 312 | 321 | <div class="compose field-row"> | |
| 313 | 322 | <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label> | |
| 314 | 323 | <textarea | |
| @@ -308,7 +308,16 @@ | |||||
| 308 | > | 308 | > | ||
| 309 | </div>{/if} | 309 | </div>{/if} | ||
| 310 | </div> | 310 | </div> | ||
| 311 | - | <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} /> | 311 | + | <TextFormattingToolbar |
| 312 | + | bind:style={textStyle} | |||
| 313 | + | compact={true} | |||
| 314 | + | showFontSelector={true} | |||
| 315 | + | onSmiley={(code) => { | |||
| 316 | + | const needsSpace = draft.length > 0 && !draft.endsWith(' '); | |||
| 317 | + | draft = `${draft}${needsSpace ? ' ' : ''}${code} `; | |||
| 318 | + | document.getElementById(`im-input-${roomId}`)?.focus(); | |||
| 319 | + | }} | |||
| 320 | + | /> | |||
| 312 | <div class="compose field-row"> | 321 | <div class="compose field-row"> | ||
| 313 | <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label> | 322 | <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label> | ||
| 314 | <textarea | 323 | <textarea | ||
M src/lib/components/formatted-message.svelte +12 -2
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | |
| 5 | 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | |
| 6 | 6 | import { highlightMentionsPlain } from '../utils/mention'; | |
| 7 | + | import { replaceSmileys } from '$lib/aim/smileys'; | |
| 7 | 8 | ||
| 8 | 9 | // Props | |
| 9 | 10 | let { message, allowFormatting = true } = $props<{ | |
| @@ -20,7 +21,7 @@ | |||
| 20 | 21 | async function processMessage() { | |
| 21 | 22 | if (!allowFormatting || !message.hasFormatting) { | |
| 22 | 23 | // Still apply mention highlighting to plain text messages | |
| 23 | - | formattedContent = highlightMentionsPlain(escapeHtml(message.content)); | |
| 24 | + | formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content))); | |
| 24 | 25 | return; | |
| 25 | 26 | } | |
| 26 | 27 | ||
| @@ -52,7 +53,9 @@ | |||
| 52 | 53 | ||
| 53 | 54 | // Regular formatted text (no gradient) | |
| 54 | 55 | const styleWithoutGradient = { ...style, gradient: undefined }; | |
| 55 | - | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |
| 56 | + | formattedContent = replaceSmileys( | |
| 57 | + | await formatText(message.content, styleWithoutGradient, allowFormatting) | |
| 58 | + | ); | |
| 56 | 59 | } catch (error) { | |
| 57 | 60 | console.error('Message formatting error:', error); | |
| 58 | 61 | hasError = true; | |
| @@ -180,6 +183,13 @@ | |||
| 180 | 183 | {/if} | |
| 181 | 184 | ||
| 182 | 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 | + | } | |
| 183 | 193 | .processing { | |
| 184 | 194 | color: #666; | |
| 185 | 195 | font-style: italic; | |
| @@ -4,6 +4,7 @@ | |||||
| 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | ||
| 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | ||
| 6 | import { highlightMentionsPlain } from '../utils/mention'; | 6 | import { highlightMentionsPlain } from '../utils/mention'; | ||
| 7 | + | import { replaceSmileys } from '$lib/aim/smileys'; | |||
| 7 | 8 | ||||
| 8 | // Props | 9 | // Props | ||
| 9 | let { message, allowFormatting = true } = $props<{ | 10 | let { message, allowFormatting = true } = $props<{ | ||
| @@ -20,7 +21,7 @@ | |||||
| 20 | async function processMessage() { | 21 | async function processMessage() { | ||
| 21 | if (!allowFormatting || !message.hasFormatting) { | 22 | if (!allowFormatting || !message.hasFormatting) { | ||
| 22 | // Still apply mention highlighting to plain text messages | 23 | // Still apply mention highlighting to plain text messages | ||
| 23 | - | formattedContent = highlightMentionsPlain(escapeHtml(message.content)); | 24 | + | formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content))); |
| 24 | return; | 25 | return; | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| @@ -52,7 +53,9 @@ | |||||
| 52 | 53 | ||||
| 53 | // Regular formatted text (no gradient) | 54 | // Regular formatted text (no gradient) | ||
| 54 | const styleWithoutGradient = { ...style, gradient: undefined }; | 55 | const styleWithoutGradient = { ...style, gradient: undefined }; | ||
| 55 | - | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | 56 | + | formattedContent = replaceSmileys( |
| 57 | + | await formatText(message.content, styleWithoutGradient, allowFormatting) | |||
| 58 | + | ); | |||
| 56 | } catch (error) { | 59 | } catch (error) { | ||
| 57 | console.error('Message formatting error:', error); | 60 | console.error('Message formatting error:', error); | ||
| 58 | hasError = true; | 61 | hasError = true; | ||
| @@ -180,6 +183,13 @@ | |||||
| 180 | {/if} | 183 | {/if} | ||
| 181 | 184 | ||||
| 182 | <style> | 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 | + | } | |||
| 183 | .processing { | 193 | .processing { | ||
| 184 | color: #666; | 194 | color: #666; | ||
| 185 | font-style: italic; | 195 | font-style: italic; | ||
M src/lib/components/text-formatting-toolbar.svelte +76 -1
| @@ -6,18 +6,24 @@ | |||
| 6 | 6 | type FontFamily | |
| 7 | 7 | } from '../types/text-formatting'; | |
| 8 | 8 | import ColorPicker256 from './color-picker-256.svelte'; | |
| 9 | + | import { SMILEYS } from '$lib/aim/smileys'; | |
| 9 | 10 | ||
| 10 | 11 | // Props | |
| 11 | 12 | let { | |
| 12 | 13 | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 13 | 14 | compact = true, | |
| 14 | - | showFontSelector = false | |
| 15 | + | showFontSelector = false, | |
| 16 | + | onSmiley | |
| 15 | 17 | } = $props<{ | |
| 16 | 18 | style?: TextStyle; | |
| 17 | 19 | compact?: boolean; | |
| 18 | 20 | showFontSelector?: boolean; | |
| 21 | + | /** When set, shows the AIM smiley picker and inserts the chosen code */ | |
| 22 | + | onSmiley?: (code: string) => void; | |
| 19 | 23 | }>(); | |
| 20 | 24 | ||
| 25 | + | let smileyOpen = $state(false); | |
| 26 | + | ||
| 21 | 27 | // State | |
| 22 | 28 | let currentColor = $state(style.color || '#000000'); | |
| 23 | 29 | let currentGradient = $state(style.gradient); | |
| @@ -132,6 +138,38 @@ | |||
| 132 | 138 | </button> | |
| 133 | 139 | </div> | |
| 134 | 140 | ||
| 141 | + | {#if onSmiley} | |
| 142 | + | <div class="smiley-control"> | |
| 143 | + | <button | |
| 144 | + | type="button" | |
| 145 | + | title="Smileys" | |
| 146 | + | aria-haspopup="true" | |
| 147 | + | aria-expanded={smileyOpen} | |
| 148 | + | onclick={() => (smileyOpen = !smileyOpen)} | |
| 149 | + | style="width: 24px; height: 20px; padding: 0; min-width: 0;" | |
| 150 | + | > | |
| 151 | + | <img src="/smileys/01.gif" alt="🙂" width="15" height="15" /> | |
| 152 | + | </button> | |
| 153 | + | {#if smileyOpen} | |
| 154 | + | <div class="smiley-picker" role="menu"> | |
| 155 | + | {#each SMILEYS as smiley (smiley.file)} | |
| 156 | + | <button | |
| 157 | + | type="button" | |
| 158 | + | role="menuitem" | |
| 159 | + | title="{smiley.label} {smiley.codes[0]}" | |
| 160 | + | onclick={() => { | |
| 161 | + | smileyOpen = false; | |
| 162 | + | onSmiley(smiley.codes[0]); | |
| 163 | + | }} | |
| 164 | + | > | |
| 165 | + | <img src="/smileys/{smiley.file}.gif" alt={smiley.emoji} width="19" height="19" /> | |
| 166 | + | </button> | |
| 167 | + | {/each} | |
| 168 | + | </div> | |
| 169 | + | {/if} | |
| 170 | + | </div> | |
| 171 | + | {/if} | |
| 172 | + | ||
| 135 | 173 | <!-- Color controls --> | |
| 136 | 174 | <div class="color-controls"> | |
| 137 | 175 | <ColorPicker256 | |
| @@ -177,6 +215,43 @@ | |||
| 177 | 215 | gap: 1px; | |
| 178 | 216 | } | |
| 179 | 217 | ||
| 218 | + | .smiley-control { | |
| 219 | + | position: relative; | |
| 220 | + | display: flex; | |
| 221 | + | } | |
| 222 | + | .smiley-control img { | |
| 223 | + | image-rendering: pixelated; | |
| 224 | + | } | |
| 225 | + | .smiley-picker { | |
| 226 | + | position: absolute; | |
| 227 | + | bottom: calc(100% + 2px); | |
| 228 | + | left: 0; | |
| 229 | + | z-index: 60; | |
| 230 | + | display: grid; | |
| 231 | + | grid-template-columns: repeat(4, 25px); | |
| 232 | + | gap: 1px; | |
| 233 | + | padding: 3px; | |
| 234 | + | background: #fff; | |
| 235 | + | border: 1px solid #7f9db9; | |
| 236 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); | |
| 237 | + | } | |
| 238 | + | .smiley-picker button { | |
| 239 | + | width: 25px; | |
| 240 | + | height: 25px; | |
| 241 | + | min-width: 0; | |
| 242 | + | min-height: 0; | |
| 243 | + | padding: 0; | |
| 244 | + | background: transparent; | |
| 245 | + | box-shadow: none; | |
| 246 | + | border: 1px solid transparent; | |
| 247 | + | display: flex; | |
| 248 | + | align-items: center; | |
| 249 | + | justify-content: center; | |
| 250 | + | } | |
| 251 | + | .smiley-picker button:hover { | |
| 252 | + | border-color: #316ac5; | |
| 253 | + | background: #e8eefb; | |
| 254 | + | } | |
| 180 | 255 | .color-controls { | |
| 181 | 256 | display: flex; | |
| 182 | 257 | gap: 1px; | |
| @@ -6,18 +6,24 @@ | |||||
| 6 | type FontFamily | 6 | type FontFamily | ||
| 7 | } from '../types/text-formatting'; | 7 | } from '../types/text-formatting'; | ||
| 8 | import ColorPicker256 from './color-picker-256.svelte'; | 8 | import ColorPicker256 from './color-picker-256.svelte'; | ||
| 9 | + | import { SMILEYS } from '$lib/aim/smileys'; | |||
| 9 | 10 | ||||
| 10 | // Props | 11 | // Props | ||
| 11 | let { | 12 | let { | ||
| 12 | style = $bindable(DEFAULT_TEXT_STYLE), | 13 | style = $bindable(DEFAULT_TEXT_STYLE), | ||
| 13 | compact = true, | 14 | compact = true, | ||
| 14 | - | showFontSelector = false | 15 | + | showFontSelector = false, |
| 16 | + | onSmiley | |||
| 15 | } = $props<{ | 17 | } = $props<{ | ||
| 16 | style?: TextStyle; | 18 | style?: TextStyle; | ||
| 17 | compact?: boolean; | 19 | compact?: boolean; | ||
| 18 | showFontSelector?: boolean; | 20 | showFontSelector?: boolean; | ||
| 21 | + | /** When set, shows the AIM smiley picker and inserts the chosen code */ | |||
| 22 | + | onSmiley?: (code: string) => void; | |||
| 19 | }>(); | 23 | }>(); | ||
| 20 | 24 | ||||
| 25 | + | let smileyOpen = $state(false); | |||
| 26 | + | ||||
| 21 | // State | 27 | // State | ||
| 22 | let currentColor = $state(style.color || '#000000'); | 28 | let currentColor = $state(style.color || '#000000'); | ||
| 23 | let currentGradient = $state(style.gradient); | 29 | let currentGradient = $state(style.gradient); | ||
| @@ -132,6 +138,38 @@ | |||||
| 132 | </button> | 138 | </button> | ||
| 133 | </div> | 139 | </div> | ||
| 134 | 140 | ||||
| 141 | + | {#if onSmiley} | |||
| 142 | + | <div class="smiley-control"> | |||
| 143 | + | <button | |||
| 144 | + | type="button" | |||
| 145 | + | title="Smileys" | |||
| 146 | + | aria-haspopup="true" | |||
| 147 | + | aria-expanded={smileyOpen} | |||
| 148 | + | onclick={() => (smileyOpen = !smileyOpen)} | |||
| 149 | + | style="width: 24px; height: 20px; padding: 0; min-width: 0;" | |||
| 150 | + | > | |||
| 151 | + | <img src="/smileys/01.gif" alt="🙂" width="15" height="15" /> | |||
| 152 | + | </button> | |||
| 153 | + | {#if smileyOpen} | |||
| 154 | + | <div class="smiley-picker" role="menu"> | |||
| 155 | + | {#each SMILEYS as smiley (smiley.file)} | |||
| 156 | + | <button | |||
| 157 | + | type="button" | |||
| 158 | + | role="menuitem" | |||
| 159 | + | title="{smiley.label} {smiley.codes[0]}" | |||
| 160 | + | onclick={() => { | |||
| 161 | + | smileyOpen = false; | |||
| 162 | + | onSmiley(smiley.codes[0]); | |||
| 163 | + | }} | |||
| 164 | + | > | |||
| 165 | + | <img src="/smileys/{smiley.file}.gif" alt={smiley.emoji} width="19" height="19" /> | |||
| 166 | + | </button> | |||
| 167 | + | {/each} | |||
| 168 | + | </div> | |||
| 169 | + | {/if} | |||
| 170 | + | </div> | |||
| 171 | + | {/if} | |||
| 172 | + | ||||
| 135 | <!-- Color controls --> | 173 | <!-- Color controls --> | ||
| 136 | <div class="color-controls"> | 174 | <div class="color-controls"> | ||
| 137 | <ColorPicker256 | 175 | <ColorPicker256 | ||
| @@ -177,6 +215,43 @@ | |||||
| 177 | gap: 1px; | 215 | gap: 1px; | ||
| 178 | } | 216 | } | ||
| 179 | 217 | ||||
| 218 | + | .smiley-control { | |||
| 219 | + | position: relative; | |||
| 220 | + | display: flex; | |||
| 221 | + | } | |||
| 222 | + | .smiley-control img { | |||
| 223 | + | image-rendering: pixelated; | |||
| 224 | + | } | |||
| 225 | + | .smiley-picker { | |||
| 226 | + | position: absolute; | |||
| 227 | + | bottom: calc(100% + 2px); | |||
| 228 | + | left: 0; | |||
| 229 | + | z-index: 60; | |||
| 230 | + | display: grid; | |||
| 231 | + | grid-template-columns: repeat(4, 25px); | |||
| 232 | + | gap: 1px; | |||
| 233 | + | padding: 3px; | |||
| 234 | + | background: #fff; | |||
| 235 | + | border: 1px solid #7f9db9; | |||
| 236 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); | |||
| 237 | + | } | |||
| 238 | + | .smiley-picker button { | |||
| 239 | + | width: 25px; | |||
| 240 | + | height: 25px; | |||
| 241 | + | min-width: 0; | |||
| 242 | + | min-height: 0; | |||
| 243 | + | padding: 0; | |||
| 244 | + | background: transparent; | |||
| 245 | + | box-shadow: none; | |||
| 246 | + | border: 1px solid transparent; | |||
| 247 | + | display: flex; | |||
| 248 | + | align-items: center; | |||
| 249 | + | justify-content: center; | |||
| 250 | + | } | |||
| 251 | + | .smiley-picker button:hover { | |||
| 252 | + | border-color: #316ac5; | |||
| 253 | + | background: #e8eefb; | |||
| 254 | + | } | |||
| 180 | .color-controls { | 255 | .color-controls { | ||
| 181 | display: flex; | 256 | display: flex; | ||
| 182 | gap: 1px; | 257 | gap: 1px; | ||
A static/smileys/README.md +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | # Smileys AIM (images locales) | |
| 2 | + | ||
| 3 | + | Ce dossier est ignoré par git (sauf ce fichier) : les smileys jaunes | |
| 4 | + | d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués. | |
| 5 | + | ||
| 6 | + | Sans fichier, chaque smiley s'affiche avec son emoji Unicode de secours. | |
| 7 | + | Pour les vrais smileys, déposer ici `01.gif` … `16.gif`, tirés du pack | |
| 8 | + | « Yellow Smiley » (`0201D201A5`) de l'archive | |
| 9 | + | <https://archive.org/details/aol_instant_messenger_smiley_packs> | |
| 10 | + | (`smiley_yellow_NN.gif` → `NN.gif`). Codes : `src/lib/aim/smileys.ts`. | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | # Smileys AIM (images locales) | |||
| 2 | + | ||||
| 3 | + | Ce dossier est ignoré par git (sauf ce fichier) : les smileys jaunes | |||
| 4 | + | d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués. | |||
| 5 | + | ||||
| 6 | + | Sans fichier, chaque smiley s'affiche avec son emoji Unicode de secours. | |||
| 7 | + | Pour les vrais smileys, déposer ici `01.gif` … `16.gif`, tirés du pack | |||
| 8 | + | « Yellow Smiley » (`0201D201A5`) de l'archive | |||
| 9 | + | <https://archive.org/details/aol_instant_messenger_smiley_packs> | |||
| 10 | + | (`smiley_yellow_NN.gif` → `NN.gif`). Codes : `src/lib/aim/smileys.ts`. | |||