Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,6 @@ if (renderFileButton != null) {
virtualizer?.setup(globalThis.document);
const wrap = getWrapped();
const editor = new Editor<LineCommentMetadata>({
clipboard: navigator.clipboard,
enabledSelectionAction: true,
renderSelectionAction: (ctx) => {
const div = document.createElement('div');
Expand Down
14 changes: 14 additions & 0 deletions packages/diffs/src/editor/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type EditorCommand =
| 'copyLineDown'
| 'simplifySelection'
| 'insertBlankLine'
| 'deleteHardLineForward'
| 'toggleComment'
| 'toggleBlockComment'
| 'moveCursorToDocStart'
Expand All @@ -38,6 +39,19 @@ export function resolveEditorCommandFromKeyboardEvent(

const normalizedKey = key.length === 1 ? key.toLowerCase() : key;

// Safari and Firefox do not report macOS control+k as a beforeinput action,
// so resolve the native delete-to-line-end shortcut from keydown instead.
if (
isMac &&
event.ctrlKey &&
!event.metaKey &&
!shiftKey &&
!altKey &&
(normalizedKey === 'k' || event.code === 'KeyK')
) {
return 'deleteHardLineForward';
}

if (
!event.metaKey &&
!event.ctrlKey &&
Expand Down
193 changes: 126 additions & 67 deletions packages/diffs/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
getDocumentFullSelection,
getSelectedLineBlocks,
getSelectionAnchor,
getSelectionClipboardTexts,
getSelectionText,
isCollapsedSelection,
isLineEditable,
Expand Down Expand Up @@ -110,7 +111,7 @@ import {
Metrics,
snapTextOffsetToUnicodeBoundary,
} from './textMeasure';
import { EditorTokenizer, renderLineTokens } from './tokenzier';
import { EditorTokenizer, renderLineTokens } from './tokenizer';
import {
addEventListener,
clampDomOffset,
Expand Down Expand Up @@ -171,7 +172,7 @@ export interface EditorOptions<LAnnotation> {
* see https://www.electronjs.org/docs/latest/api/clipboard
*/
clipboard?: {
readText: () => Promise<string> | string;
readText: (type?: string) => Promise<string> | string;
};
/** Render the selection action widget element. */
renderSelectionAction?: (
Expand Down Expand Up @@ -202,6 +203,8 @@ export interface EditorOptions<LAnnotation> {
// row per inserted line. A safety bound, not a correctness-critical value.
const MAX_EDIT_WIDEN_WINDOW_MULTIPLE = 2;
const SELECTION_ACTION_POPOVER_PLACEMENT_KEY = 'selection-action';
const MULTI_SELECTION_CLIPBOARD_TYPE =
'application/vnd.pierre.diffs-selections+json';

export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
#options: EditorOptions<LAnnotation>;
Expand Down Expand Up @@ -1442,12 +1445,15 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
// A read-only deleted-text selection lives outside the editor's
// document, so #getSelectionText() would be empty. Copy the selected
// deleted text instead.
e.clipboardData?.setData(
'text',
this.#isDeletedTextSelectionActive()
? this.#deletedTextForClipboard()
: this.#getSelectionText()
);
if (this.#isDeletedTextSelectionActive()) {
e.clipboardData?.setData('text', this.#deletedTextForClipboard());
} else {
this.#writeSelectionClipboardData(
e.clipboardData,
this.#getSelectionText(),
this.#getSelectionClipboardTexts()
);
}
}),

addEventListener(contentEl, 'cut', (e) => {
Expand All @@ -1457,31 +1463,61 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
e.preventDefault();
// Deleted text is read-only and can't be removed, so a cut there copies
// the selected deleted text (like the copy handler) without editing.
e.clipboardData?.setData(
'text',
this.#isDeletedTextSelectionActive()
? this.#deletedTextForClipboard()
: this.#cutSelectionText()
);
if (this.#isDeletedTextSelectionActive()) {
e.clipboardData?.setData('text', this.#deletedTextForClipboard());
} else {
const selectionTexts = this.#getSelectionClipboardTexts();
this.#writeSelectionClipboardData(
e.clipboardData,
this.#cutSelectionText(),
selectionTexts
);
}
}),

addEventListener(contentEl, 'paste', (e) => {
if (!targetIsContentElement(e)) {
return;
}
e.preventDefault();
const text = e.clipboardData?.getData('text');
const clipboardData = e.clipboardData;
const textDocument = this.#textDocument;
if (text !== undefined && textDocument !== undefined) {
// Rewrite clipboard line breaks to the document's EOL so a Windows
// clipboard (\r\n or \r) doesn't leave mixed line endings behind.
// TODO(@ije): Add support of multiple selections copy&paste
this.#replaceSelectionText(
textDocument.normalizeEol(text),
undefined,
true
if (clipboardData === null || textDocument === undefined) {
return;
}

let text: string | string[] = clipboardData.getData('text');

const selectionCount = this.#selections?.length ?? 0;
if (selectionCount > 1) {
const multiSelectionText = clipboardData.getData(
MULTI_SELECTION_CLIPBOARD_TYPE
);
if (multiSelectionText !== undefined) {
try {
const selectionTexts = JSON.parse(multiSelectionText);
if (
Array.isArray(selectionTexts) &&
selectionTexts.length === selectionCount
) {
text = selectionTexts;
Comment on lines +1498 to +1503

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve plain text when counts differ

When pasting into multiple selections, this accepts any non-empty metadata array without checking that it matches the current selection count. If a user copies two whole lines and pastes with three cursors, #replaceSelectionText falls back to text.join(textDocument.eol) instead of the plain clipboard entry, so entries that already include trailing line breaks are pasted with extra blank lines at every cursor. Keep using the plain clipboard text unless the metadata length matches the target selections.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in b9f5b40

}
} catch {
// ignore the invalid custom clipboard data
}
}
}

// Rewrite clipboard line breaks to the document's EOL so a Windows
// clipboard (\r\n or \r) doesn't leave mixed line endings behind.
this.#replaceSelectionText(
Array.isArray(text)
? text.map((t) => textDocument.normalizeEol(t))
: textDocument.normalizeEol(text),
undefined,
true,
'document'
);
}),

addEventListener(contentEl, 'beforeinput', (e) => {
Expand Down Expand Up @@ -1729,13 +1765,33 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
#handleCustomPasteEvent = async () => {
const clipboard = this.#options.clipboard;
if (clipboard !== undefined) {
const text = await clipboard.readText();
let text: string | string[] = await clipboard.readText();
const selectionCount = this.#selections?.length ?? 0;
if (selectionCount > 1) {
const multiSelectionText = await clipboard.readText(
MULTI_SELECTION_CLIPBOARD_TYPE
);
try {
const selectionTexts = JSON.parse(multiSelectionText);
if (
Array.isArray(selectionTexts) &&
selectionTexts.length === selectionCount
) {
text = selectionTexts;
}
} catch {
// Invalid selection metadata falls back to the plain-text value.
}
}
const textDocument = this.#textDocument;
if (textDocument !== undefined) {
this.#replaceSelectionText(
textDocument.normalizeEol(text),
Array.isArray(text)
? text.map((t) => textDocument.normalizeEol(t))
: textDocument.normalizeEol(text),
undefined,
true
true,
'document'
);
}
}
Expand Down Expand Up @@ -1769,7 +1825,6 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
return undefined;
}

// TODO(@ije): add command registry
#runCommand(command: EditorCommand) {
const textDocument = this.#textDocument;
if (textDocument === undefined) {
Expand Down Expand Up @@ -1847,6 +1902,10 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
this.#insertBlankLine();
break;

case 'deleteHardLineForward':
this.#deleteHardLineForward();
break;

case 'toggleComment':
case 'toggleBlockComment': {
const selections = this.#selections;
Expand Down Expand Up @@ -2452,7 +2511,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
return;
}

// cancel existing background tokenzier task
// cancel existing background tokenizing task
tokenizer.stopBackgroundTokenize();

const t = performance.now();
Expand Down Expand Up @@ -2678,11 +2737,6 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
case 'deleteHardLineBackward':
this.#deleteSoftLineBackward();
break;
case 'deleteHardLineForward':
// TODO(@ije): Safari and Firefox does not support this input type
// use command instead
this.#deleteHardLineForward();
break;
case 'deleteWordBackward':
this.#deleteWordBackward();
break;
Expand Down Expand Up @@ -3514,12 +3568,13 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
const top = this.#getLineY(line) + wrapLine * lineHeight;
// Match the corner mask to the line color behind the selection; when
// absent (context lines) the CSS falls back to the editor base bg.
const cornerBg = this.#lineBackgroundColor(line);
const css =
`width:${ch}px;transform:translateX(${left}px) translateY(${top}px);` +
(cornerBg !== undefined
? `--diffs-selection-corner-bg:${cornerBg};`
: '');
const lineElement = this.#getLineElement(line);
let cornerBg = 'initial';
if (this.#isDiff && lineElement?.dataset.lineType === 'change-addition') {
cornerBg =
getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg');
}
const css = `width:${ch}px;transform:translateX(${left}px) translateY(${top}px);--diffs-selection-corner-bg:${cornerBg}`;
const dataset = {
selectionCorner: '',
[radius]: '',
Expand Down Expand Up @@ -3822,7 +3877,6 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
this.#renderSearchPanel(mode);
}

// TODO(@ije): render search highlight
#renderSearchPanel(mode: SearchPanelMode) {
// cleanup the existing search panel
this.#searchPanel?.cleanup();
Expand Down Expand Up @@ -3971,6 +4025,33 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
return getSelectionText(textDocument, selections);
}

#getSelectionClipboardTexts(): string[] {
const textDocument = this.#textDocument;
const selections = this.#selections;
if (textDocument === undefined || selections === undefined) {
return [];
}
return getSelectionClipboardTexts(textDocument, selections);
}

/** Writes both the portable text and the selection pairing metadata. */
#writeSelectionClipboardData(
clipboardData: DataTransfer | null,
text: string,
selectionTexts: string[]
): void {
if (clipboardData === null) {
return;
}
clipboardData.setData('text', text);
if (selectionTexts.length > 1) {
clipboardData.setData(
MULTI_SELECTION_CLIPBOARD_TYPE,
JSON.stringify(selectionTexts)
);
}
}

#cutSelectionText(): string {
const textDocument = this.#textDocument;
const selections = this.#selections;
Expand Down Expand Up @@ -4040,7 +4121,8 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
#replaceSelectionText(
text: string | string[],
selections = this.#selections,
undoBoundary = false
undoBoundary = false,
textOrder: 'selection' | 'document' = 'selection'
) {
if (selections === undefined) {
return;
Expand All @@ -4057,15 +4139,16 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
selections,
text,
this.#lineAnnotations,
undoBoundary
undoBoundary,
textOrder
)
: applyTextChangeToSelections<LAnnotation>(
textDocument,
selections,
{
start: textDocument.offsetAt(primarySelection.start),
end: textDocument.offsetAt(primarySelection.end),
text: Array.isArray(text) ? text.join('\n') : text,
text: Array.isArray(text) ? text.join(textDocument.eol) : text,
},
this.#lineAnnotations,
undefined,
Expand Down Expand Up @@ -4367,30 +4450,6 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
return undefined;
}

// TODO(@ije): remove this
// Painted background color of a line, read from the [data-line]::after layer
// (the line element itself is transparent in edit mode). Returns undefined when
// that layer is transparent (e.g. context lines).
#lineBackgroundColor(line: number): string | undefined {
const lineElement = this.#getLineElement(line);
if (lineElement === undefined) {
return undefined;
}
// testing environment like jsdom doesn't implement the getComputedStyle API
if (navigator.userAgent.includes('jsdom')) {
return undefined;
}
const backgroundColor = getComputedStyle(
lineElement,
'::after'
).backgroundColor;
return backgroundColor === '' ||
backgroundColor === 'transparent' ||
backgroundColor === 'rgba(0, 0, 0, 0)'
? undefined
: backgroundColor;
}

// Returns the first and last document lines that have an editable row in the
// current (virtualized) render window, or undefined when none are rendered.
// Used by select-all to anchor a native selection: only rendered lines
Expand Down
2 changes: 1 addition & 1 deletion packages/diffs/src/editor/matchBrackets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Position, Range } from '../types';
import type { TextDocument } from './textDocument';
import type { EditorTokenizer } from './tokenzier';
import type { EditorTokenizer } from './tokenizer';

const OPEN_BRACKETS = new Map([
['(', ')'],
Expand Down
Loading