[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: improve mention chips

+255 -30
+81 -27
app/components/admin/PostEditor.vue
··· 1 1 <script setup lang="ts"> 2 + import { serialiseEditor } from './post-editor-serialise' 2 3 import type { DevRoeEntity } from '#shared/lex' 3 4 4 5 /** ··· 92 93 } 93 94 94 95 function serialiseFromDom (): string { 95 - if (!editorRef.value) return '' 96 - let out = '' 97 - for (const node of editorRef.value.childNodes) { 98 - if (node.nodeType === Node.TEXT_NODE) { 99 - out += node.textContent ?? '' 100 - } 101 - else if (node instanceof HTMLElement) { 102 - if (node.dataset.mentionRkey) { 103 - out += `@${node.dataset.mentionRkey}` 104 - } 105 - else if (node.dataset.linkUri) { 106 - out += `[${node.textContent ?? ''}](${node.dataset.linkUri})` 107 - } 108 - else if (node.tagName === 'BR') { 109 - out += '\n' 110 - } 111 - else if (node.tagName === 'DIV' || node.tagName === 'P') { 112 - // Browsers wrap newlines in <div>/<p> on Enter. 113 - if (out && !out.endsWith('\n')) out += '\n' 114 - out += node.textContent ?? '' 115 - } 116 - else { 117 - out += node.textContent ?? '' 118 - } 119 - } 120 - } 121 - return out 96 + return editorRef.value ? serialiseEditor(editorRef.value) : '' 122 97 } 123 98 124 99 function onInput () { 125 100 emit('update:modelValue', serialiseFromDom()) 126 101 } 127 102 103 + function selectionInEditor (): Range | null { 104 + const editor = editorRef.value 105 + if (!editor) return null 106 + const sel = window.getSelection() 107 + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null 108 + const range = sel.getRangeAt(0) 109 + if (!editor.contains(range.commonAncestorContainer)) return null 110 + return range 111 + } 112 + 113 + // Copy/cut write the storage form (`@<rkey>` / `[label](url)`) into 114 + // `text/plain` so chips survive a round-trip through the OS clipboard. 115 + // Without this, the browser's default serialiser keeps the chip's 116 + // visible label (`@Vercel`) and drops the `data-mention-rkey` attribute 117 + // on paste, silently turning the mention into plain text. 118 + function writeSelectionToClipboard (event: ClipboardEvent) { 119 + const range = selectionInEditor() 120 + if (!range || !event.clipboardData) return 121 + const wrapper = document.createElement('div') 122 + wrapper.appendChild(range.cloneContents()) 123 + const text = serialiseEditor(wrapper) 124 + event.clipboardData.setData('text/plain', text) 125 + event.preventDefault() 126 + } 127 + 128 + function onCopy (event: ClipboardEvent) { 129 + writeSelectionToClipboard(event) 130 + } 131 + 132 + function onCut (event: ClipboardEvent) { 133 + const range = selectionInEditor() 134 + if (!range) return 135 + writeSelectionToClipboard(event) 136 + range.deleteContents() 137 + onInput() 138 + } 139 + 140 + // Paste through the storage form: the source editor wrote `@<rkey>` 141 + // into `text/plain`, so we insert that as text and then re-render the 142 + // whole editor from the resulting serialised value, which rebuilds 143 + // chips at the paste site. 144 + function onPaste (event: ClipboardEvent) { 145 + const editor = editorRef.value 146 + if (!editor || !event.clipboardData) return 147 + const text = event.clipboardData.getData('text/plain') 148 + if (!text) return 149 + event.preventDefault() 150 + 151 + const sel = window.getSelection() 152 + if (!sel || sel.rangeCount === 0 || !editor.contains(sel.anchorNode)) { 153 + editor.appendChild(document.createTextNode(text)) 154 + } 155 + else { 156 + const range = sel.getRangeAt(0) 157 + range.deleteContents() 158 + const node = document.createTextNode(text) 159 + range.insertNode(node) 160 + range.setStartAfter(node) 161 + range.setEndAfter(node) 162 + sel.removeAllRanges() 163 + sel.addRange(range) 164 + } 165 + 166 + const next = serialiseFromDom() 167 + renderToDom(next) 168 + // renderToDom rebuilds child nodes, so the previous range is detached. 169 + // Drop the caret at the end of the editor; matches default paste UX. 170 + const endRange = document.createRange() 171 + endRange.selectNodeContents(editor) 172 + endRange.collapse(false) 173 + const sel2 = window.getSelection() 174 + sel2?.removeAllRanges() 175 + sel2?.addRange(endRange) 176 + emit('update:modelValue', next) 177 + } 178 + 128 179 function insertNode (node: Node) { 129 180 const editor = editorRef.value 130 181 if (!editor) return ··· 187 238 :data-placeholder="placeholder" 188 239 class="bg-accent px-3 py-2 text-sm w-full min-h-[6rem] whitespace-pre-wrap break-words empty:before:content-[attr(data-placeholder)] empty:before:text-muted" 189 240 @input="onInput" 241 + @copy="onCopy" 242 + @cut="onCut" 243 + @paste="onPaste" 190 244 /> 191 245 </template>
+40
app/components/admin/post-editor-serialise.ts
··· 1 + /** 2 + * Serialise the PostEditor contenteditable DOM back to its flat-text 3 + * storage form (`@<rkey>` for entity mentions, `[label](url)` for 4 + * inline links, `\n` for line breaks). Exported so it can be unit 5 + * tested against fixtures built with happy-dom; the component holds the 6 + * editor element, this function holds the rules. 7 + */ 8 + 9 + function serialiseNode (node: Node): string { 10 + if (node.nodeType === Node.TEXT_NODE) { 11 + return node.textContent ?? '' 12 + } 13 + if (!(node instanceof HTMLElement)) return '' 14 + 15 + if (node.dataset.mentionRkey) { 16 + return `@${node.dataset.mentionRkey}` 17 + } 18 + if (node.dataset.linkUri) { 19 + return `[${node.textContent ?? ''}](${node.dataset.linkUri})` 20 + } 21 + if (node.tagName === 'BR') { 22 + return '\n' 23 + } 24 + 25 + let inner = '' 26 + for (const child of node.childNodes) inner += serialiseNode(child) 27 + 28 + // Browsers wrap newlines in <div>/<p> on Enter, and may nest chips 29 + // inside them. Recursing keeps chip semantics across line breaks. 30 + if (node.tagName === 'DIV' || node.tagName === 'P') { 31 + return (inner.startsWith('\n') ? '' : '\n') + inner 32 + } 33 + return inner 34 + } 35 + 36 + export function serialiseEditor (root: HTMLElement): string { 37 + let out = '' 38 + for (const node of root.childNodes) out += serialiseNode(node) 39 + return out.replace(/^\n+/, '') 40 + }
+1 -3
server/utils/admin/ama-publish.ts
··· 288 288 }, 289 289 }) 290 290 291 - const activityId = post.id.split(':').pop() 292 - const identifier = config.social?.networks?.linkedin?.identifier || 'daniel-roe' 293 - return { url: `https://www.linkedin.com/posts/${identifier}_${activityId}` } 291 + return { url: `https://www.linkedin.com/feed/update/${post.id}/` } 294 292 }
+133
test/unit/post-editor-serialise.spec.ts
··· 1 + import { describe, it, expect, beforeAll } from 'vitest' 2 + import { Window } from 'happy-dom' 3 + 4 + import { serialiseEditor } from '../../app/components/admin/post-editor-serialise' 5 + 6 + let window: Window 7 + let document: Window['document'] 8 + 9 + beforeAll(() => { 10 + window = new Window() 11 + document = window.document 12 + // happy-dom doesn't expose Node/HTMLElement as globals by default, but 13 + // the serialiser relies on `instanceof HTMLElement` and the `Node` 14 + // constants. Mirror them onto the global scope for the duration of 15 + // the tests. 16 + for (const key of ['Node', 'HTMLElement', 'Text']) { 17 + // @ts-expect-error - test-only globals 18 + globalThis[key] = window[key] 19 + } 20 + }) 21 + 22 + function mentionChip (rkey: string, label: string): HTMLElement { 23 + const span = document.createElement('span') 24 + span.dataset.mentionRkey = rkey 25 + span.textContent = label 26 + return span as unknown as HTMLElement 27 + } 28 + 29 + function linkChip (label: string, url: string): HTMLElement { 30 + const a = document.createElement('a') 31 + a.dataset.linkUri = url 32 + a.textContent = label 33 + return a as unknown as HTMLElement 34 + } 35 + 36 + function editor (build: (root: HTMLElement) => void): HTMLElement { 37 + const root = document.createElement('div') 38 + build(root as unknown as HTMLElement) 39 + return root as unknown as HTMLElement 40 + } 41 + 42 + describe('serialiseEditor', () => { 43 + it('serialises plain text', () => { 44 + const root = editor(r => { 45 + r.appendChild(document.createTextNode('hello world')) 46 + }) 47 + expect(serialiseEditor(root)).toBe('hello world') 48 + }) 49 + 50 + it('serialises mention chips as @<rkey>', () => { 51 + const root = editor(r => { 52 + r.appendChild(document.createTextNode('hi ')) 53 + r.appendChild(mentionChip('abcdefghijklm', '@Vercel')) 54 + r.appendChild(document.createTextNode(' team')) 55 + }) 56 + expect(serialiseEditor(root)).toBe('hi @abcdefghijklm team') 57 + }) 58 + 59 + it('serialises link chips as [label](url)', () => { 60 + const root = editor(r => { 61 + r.appendChild(document.createTextNode('see ')) 62 + r.appendChild(linkChip('docs', 'https://example.com')) 63 + }) 64 + expect(serialiseEditor(root)).toBe('see [docs](https://example.com)') 65 + }) 66 + 67 + it('treats <br> as newline', () => { 68 + const root = editor(r => { 69 + r.appendChild(document.createTextNode('one')) 70 + r.appendChild(document.createElement('br')) 71 + r.appendChild(document.createTextNode('two')) 72 + }) 73 + expect(serialiseEditor(root)).toBe('one\ntwo') 74 + }) 75 + 76 + it('preserves chip semantics inside <div> wrappers (Enter creates a new line)', () => { 77 + // Chrome's contenteditable wraps the new line in a <div> when the 78 + // user presses Enter. If the mention chip ends up inside that <div> 79 + // the serialiser must keep emitting `@<rkey>`, not the visible 80 + // `@Vercel` text content. 81 + const root = editor(r => { 82 + r.appendChild(document.createTextNode('first line')) 83 + const div = document.createElement('div') 84 + div.appendChild(document.createTextNode('as I work at ')) 85 + div.appendChild(mentionChip('abcdefghijklm', '@Vercel')) 86 + div.appendChild(document.createTextNode(', ...')) 87 + r.appendChild(div) 88 + }) 89 + expect(serialiseEditor(root)).toBe('first line\nas I work at @abcdefghijklm, ...') 90 + }) 91 + 92 + it('preserves link chips inside <div> wrappers', () => { 93 + const root = editor(r => { 94 + const div = document.createElement('div') 95 + div.appendChild(document.createTextNode('see ')) 96 + div.appendChild(linkChip('docs', 'https://example.com')) 97 + r.appendChild(div) 98 + }) 99 + expect(serialiseEditor(root)).toBe('see [docs](https://example.com)') 100 + }) 101 + 102 + it('joins consecutive <div> blocks with newlines', () => { 103 + const root = editor(r => { 104 + const a = document.createElement('div') 105 + a.appendChild(document.createTextNode('one')) 106 + const b = document.createElement('div') 107 + b.appendChild(document.createTextNode('two')) 108 + r.appendChild(a) 109 + r.appendChild(b) 110 + }) 111 + expect(serialiseEditor(root)).toBe('one\ntwo') 112 + }) 113 + 114 + it('handles empty editor', () => { 115 + const root = editor(() => {}) 116 + expect(serialiseEditor(root)).toBe('') 117 + }) 118 + 119 + it('serialises a cloned selection fragment (copy/cut path)', () => { 120 + // The copy/cut handler clones the selection into a fresh wrapper 121 + // and runs it through the same serialiser. Confirm a fragment 122 + // containing just a chip + trailing text round-trips. 123 + const root = editor(r => { 124 + r.appendChild(document.createTextNode('hi ')) 125 + r.appendChild(mentionChip('abcdefghijklm', '@Vercel')) 126 + r.appendChild(document.createTextNode(' team')) 127 + }) 128 + const wrapper = document.createElement('div') 129 + while (root.firstChild) wrapper.appendChild(root.firstChild) 130 + expect(serialiseEditor(wrapper as unknown as HTMLElement)) 131 + .toBe('hi @abcdefghijklm team') 132 + }) 133 + })