Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

Merge pull request 'feat: bulk accept/reject suggestions + fix markdown link/task paste' (#275) from feat/bulk-suggestions-and-link-paste into main

scott 2c176dfd 719642b3

+82 -2
+17
src/css/app.css
··· 4993 4993 font-size: 0.75rem; 4994 4994 } 4995 4995 4996 + .suggestion-popover-bulk { 4997 + display: flex; 4998 + gap: var(--space-xs); 4999 + border-top: 1px solid var(--color-border); 5000 + margin-top: var(--space-xs); 5001 + padding-top: var(--space-xs); 5002 + } 5003 + 5004 + .suggestion-accept-all-btn, 5005 + .suggestion-reject-all-btn { 5006 + font-size: 0.6875rem; 5007 + color: var(--color-text-muted) !important; 5008 + } 5009 + 5010 + .suggestion-accept-all-btn:hover { color: var(--color-success) !important; } 5011 + .suggestion-reject-all-btn:hover { color: var(--color-danger) !important; } 5012 + 4996 5013 /* ======================================================== 4997 5014 Offline Indicator 4998 5015 ======================================================== */
+6 -2
src/docs/index.html
··· 308 308 <span class="suggestion-popover-type" id="suggestion-type"></span> 309 309 </div> 310 310 <div class="suggestion-popover-actions"> 311 - <button class="btn-ghost suggestion-accept-btn" id="suggestion-accept" title="Accept">&#10003; Accept</button> 312 - <button class="btn-ghost suggestion-reject-btn" id="suggestion-reject" title="Reject">&#10007; Reject</button> 311 + <button class="btn-ghost suggestion-accept-btn" id="suggestion-accept" title="Accept this suggestion">&#10003; Accept</button> 312 + <button class="btn-ghost suggestion-reject-btn" id="suggestion-reject" title="Reject this suggestion">&#10007; Reject</button> 313 + </div> 314 + <div class="suggestion-popover-bulk"> 315 + <button class="btn-ghost suggestion-accept-all-btn" id="suggestion-accept-all" title="Accept all suggestions">Accept All</button> 316 + <button class="btn-ghost suggestion-reject-all-btn" id="suggestion-reject-all" title="Reject all suggestions">Reject All</button> 313 317 </div> 314 318 </div> 315 319
+45
src/docs/main.ts
··· 1984 1984 $('suggestion-accept').addEventListener('click', () => handleSuggestionAction('accept')); 1985 1985 $('suggestion-reject').addEventListener('click', () => handleSuggestionAction('reject')); 1986 1986 1987 + function handleBulkSuggestionAction(action: 'accept' | 'reject'): void { 1988 + const insertMark = editor.schema.marks.suggestionInsert; 1989 + const deleteMark = editor.schema.marks.suggestionDelete; 1990 + const { doc } = editor.state; 1991 + 1992 + // Collect all suggestion ranges grouped by type 1993 + const inserts: { from: number; to: number }[] = []; 1994 + const deletes: { from: number; to: number }[] = []; 1995 + 1996 + doc.descendants((node, pos) => { 1997 + if (node.isText) { 1998 + for (const mark of node.marks) { 1999 + if (mark.type === insertMark) { 2000 + inserts.push({ from: pos, to: pos + node.nodeSize }); 2001 + } else if (mark.type === deleteMark) { 2002 + deletes.push({ from: pos, to: pos + node.nodeSize }); 2003 + } 2004 + } 2005 + } 2006 + }); 2007 + 2008 + if (inserts.length === 0 && deletes.length === 0) return; 2009 + 2010 + editor.chain().focus().command(({ tr }) => { 2011 + if (action === 'accept') { 2012 + // Accept inserts: keep text, remove marks 2013 + inserts.forEach(({ from, to }) => tr.removeMark(from, to, insertMark)); 2014 + // Accept deletes: delete the text (from end to start) 2015 + deletes.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 2016 + } else { 2017 + // Reject deletes: keep text, remove marks 2018 + deletes.forEach(({ from, to }) => tr.removeMark(from, to, deleteMark)); 2019 + // Reject inserts: delete the text (from end to start) 2020 + inserts.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 2021 + } 2022 + return true; 2023 + }).run(); 2024 + 2025 + suggestionPopover.style.display = 'none'; 2026 + activeSuggestion = null; 2027 + } 2028 + 2029 + $('suggestion-accept-all').addEventListener('click', () => handleBulkSuggestionAction('accept')); 2030 + $('suggestion-reject-all').addEventListener('click', () => handleBulkSuggestionAction('reject')); 2031 + 1987 2032 // In suggesting mode, wrap insertions/deletions with suggestion marks 1988 2033 // We use a ProseMirror plugin-like approach via editor transaction filtering 1989 2034 const originalDispatch = editor.view.dispatch.bind(editor.view);
+4
src/docs/markdown-paste.ts
··· 23 23 const HEADING_RE = /^#{1,6}\s+\S/m; 24 24 const CODE_FENCE_RE = /^\s*```/m; 25 25 const TABLE_WITH_SEPARATOR_RE = /^\s*\|.+\|.+\|\s*\n\s*\|[\s:|-]+\|/m; 26 + const MARKDOWN_LINK_RE = /\[.+?\]\(.+?\)/; 27 + const TASK_LIST_RE = /^\s*- \[([ xX])\]/m; 26 28 27 29 export function looksLikeMarkdown(text: string): boolean { 28 30 if (!text || !text.trim()) return false; ··· 38 40 if (HEADING_RE.test(text)) return true; 39 41 if (CODE_FENCE_RE.test(text)) return true; 40 42 if (TABLE_WITH_SEPARATOR_RE.test(text)) return true; 43 + if (MARKDOWN_LINK_RE.test(text)) return true; 44 + if (TASK_LIST_RE.test(text)) return true; 41 45 42 46 return false; 43 47 }
+10
tests/markdown-paste.test.ts
··· 20 20 expect(looksLikeMarkdown('| Col1 | Col2 |\n|:---|:---|\n| val | val |')).toBe(true); 21 21 }); 22 22 23 + it('detects markdown links as strong signal', () => { 24 + expect(looksLikeMarkdown('Check out [Example](https://example.com)')).toBe(true); 25 + expect(looksLikeMarkdown('[docs](https://docs.example.com/path)')).toBe(true); 26 + }); 27 + 28 + it('detects task lists as strong signal', () => { 29 + expect(looksLikeMarkdown('- [ ] Buy groceries')).toBe(true); 30 + expect(looksLikeMarkdown('- [x] Ship feature')).toBe(true); 31 + }); 32 + 23 33 // --- Multi-signal (2+ needed) --- 24 34 25 35 it('detects unordered list + blockquote', () => {