feat(workspace): copy bullets as Markdown, Ctrl+A twice selects the whole note
Ctrl+C over a selection of several bullets now puts Markdown on the clipboard (lib/outlineMarkdown.ts, the format of the Markdown export: tab indent, - [ ] to-dos, headings, callouts, fenced code); pasting it into a note gives the same bullets again. A second Ctrl+A in a bullet selects the whole note. Copying goes through the browser's copy event, so it works on plain HTTP too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da92ffa6b5
commit
1767764912
4 files changed
+169
-8
No files matched your search
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, u
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
|
||||
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||
import { blocksToMarkdown } from '../../lib/outlineMarkdown';
|
||||
import { numberedLabels } from '../../lib/outlineList';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
|
||||
@@ -47,6 +48,24 @@ export interface OutlinerTreeProps {
|
||||
trailingArea?: boolean;
|
||||
}
|
||||
|
||||
// Puts text on the clipboard from a key press: through the browser's copy event, which works on plain
|
||||
// HTTP too (navigator.clipboard needs HTTPS or localhost); the clipboard API only as a fallback
|
||||
function writeClipboardText(text: string) {
|
||||
const onCopy = (e: ClipboardEvent) => {
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('copy', onCopy);
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
// not supported: fall back below
|
||||
}
|
||||
document.removeEventListener('copy', onCopy);
|
||||
if (!copied) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
docId,
|
||||
compact = false,
|
||||
@@ -679,7 +698,8 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
|
||||
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
|
||||
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
|
||||
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies.
|
||||
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies
|
||||
// it as Markdown. A second Strg+A in a bullet selects the whole note.
|
||||
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
|
||||
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
|
||||
const rangeSelRef = useRef<RangeSel | null>(null);
|
||||
@@ -819,13 +839,30 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
|
||||
};
|
||||
|
||||
// The selection as Markdown (lib/outlineMarkdown.ts): pasted elsewhere it is a proper list, pasted
|
||||
// back into a note it becomes the same bullets again
|
||||
const selectedText = (sel: RangeSel) => {
|
||||
const r = resolveRangeSel(sel);
|
||||
if (!r) return '';
|
||||
const lines = [(r.first.content || '').slice(r.firstOffset)];
|
||||
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || '');
|
||||
lines.push((r.last.content || '').slice(0, r.lastOffset));
|
||||
return lines.join('\n');
|
||||
return blocksToMarkdown([
|
||||
{ ...r.first, content: (r.first.content || '').slice(r.firstOffset) },
|
||||
...blocks.slice(r.fIdx + 1, r.lIdx),
|
||||
{ ...r.last, content: (r.last.content || '').slice(0, r.lastOffset) },
|
||||
]);
|
||||
};
|
||||
|
||||
// Ctrl/Cmd+A a second time (the bullet's own text is selected already): the whole note, ready for
|
||||
// Ctrl+C as Markdown
|
||||
const handleTreeKeyDownCapture = (e: React.KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'a') return;
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
if (ta.tagName !== 'TEXTAREA' || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return;
|
||||
if (visibleEntries.length < 2) return;
|
||||
e.preventDefault();
|
||||
const first = visibleEntries[0].block;
|
||||
const last = visibleEntries[visibleEntries.length - 1].block;
|
||||
focusBlockInput(first.id, 'select');
|
||||
setRangeSel({ anchorId: first.id, anchorOffset: 0, focusId: last.id, focusOffset: (last.content || '').length });
|
||||
};
|
||||
|
||||
const deleteRangeSel = (sel: RangeSel, insert = '') => {
|
||||
@@ -882,7 +919,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
};
|
||||
if (mod && (e.key === 'c' || e.key === 'x')) {
|
||||
stop();
|
||||
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {});
|
||||
writeClipboardText(selectedTextRef.current(sel));
|
||||
if (e.key === 'x') deleteRangeSelRef.current(sel);
|
||||
return;
|
||||
}
|
||||
@@ -912,7 +949,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
|
||||
<div
|
||||
className="space-y-0.5"
|
||||
ref={treeRootRef}
|
||||
onMouseDownCapture={handleTreeMouseDownCapture}
|
||||
onKeyDownCapture={handleTreeKeyDownCapture}
|
||||
>
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||
const isLast = entryIdx === visibleEntries.length - 1;
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user