mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-10-25 09:35:36 +00:00
AppKit: Integrate Edit menu clipboard actions with the system clipboard
CPTextView previously relied solely on browser clipboard events triggered by keyboard equivalents, isolating Edit menu actions (Copy, Paste) to an internal pasteboard. Consequently, menu operations ignored external system clipboard modifications, and Command-V could mistakenly paste stale internal rich text following an external copy operation. * AppKit/CPTextView/CPTextView.j: - Distinguish menu- and button-driven clipboard actions from browser keyboard event handlers. - Implement asynchronous Clipboard API integration to write plain text to the system clipboard on copy/cut and read external content for Paste and Paste as Plain Text. - Implement intelligent clipboard reconciliation: preserve internal rich text if the system clipboard plain text matches, or ingest external system clipboard text (including for Command-V shortcuts). - Ensure Cut operates correctly across all input vectors. * Tests/AppKit/CPTextViewTest.j: - Add unit tests verifying text ingestion and pasting from the system clipboard. * Tests/Manual/TextEditor/WORKLIST.md: - Mark Edit menu clipboard synchronization as tested. - Log rich-text serialization over the asynchronous Clipboard API as an opportunity for future improvement. Copy, Cut, and Paste operations from the Edit menu now seamlessly exchange plain text with external applications.
This commit is contained in:
1 parent
a06ad56545
commit
8a70822a2f
3 files changed
+156
-16
No files matched your search
+126
-10
@@ -59,6 +59,36 @@
|
||||
|
||||
@global document
|
||||
|
||||
/*
|
||||
The system clipboard. A key equivalent such as Command-C, Command-X or Command-V lets the
|
||||
browser send its own clipboard event, which _CPNativeInputManager handles with the
|
||||
clipboard data of that event. A menu item chosen with the mouse, or a button, has no such
|
||||
event, so the text view uses the asynchronous Clipboard API instead. That API carries only
|
||||
plain text in current browsers. Rich text copied in the application stays on the internal
|
||||
pasteboard, and is pasted from there when the system clipboard still holds the same plain
|
||||
text.
|
||||
*/
|
||||
|
||||
/*
|
||||
Returns YES if two clipboard strings hold the same text, ignoring the line ending
|
||||
conventions that the system clipboard may apply.
|
||||
*/
|
||||
var _CPTextViewClipboardStringsMatch = function(aString, anotherString)
|
||||
{
|
||||
if (!aString || !anotherString)
|
||||
return NO;
|
||||
|
||||
return aString.replace(/\r\n?/g, "\n") === anotherString.replace(/\r\n?/g, "\n");
|
||||
};
|
||||
|
||||
/*
|
||||
Returns YES if the browser provides the asynchronous Clipboard API.
|
||||
*/
|
||||
var _CPTextViewHasAsyncClipboard = function()
|
||||
{
|
||||
return CPDOMAvailable && typeof navigator !== "undefined" && !!navigator.clipboard && !!navigator.clipboard.readText && !!navigator.clipboard.writeText;
|
||||
};
|
||||
|
||||
_MakeRangeFromAbs = function(a1, a2)
|
||||
{
|
||||
return (a1 < a2) ? CPMakeRange(a1, a2 - a1) : CPMakeRange(a2, a1 - a2);
|
||||
@@ -453,6 +483,15 @@ var kDelegateRespondsTo_textShouldBeginEditing
|
||||
// MARK: -
|
||||
// MARK: Copy and paste methods
|
||||
|
||||
/* @ignore
|
||||
Returns YES if a clipboard action comes from the browser's clipboard event or from a key
|
||||
equivalent. The browser then sends its own clipboard event, which carries the clipboard data.
|
||||
*/
|
||||
- (BOOL)_isKeyboardClipboardAction:(id)sender
|
||||
{
|
||||
return sender === _CPNativeInputManager || [sender isKindOfClass:_CPNativeInputManager] || [[CPApp currentEvent] type] === CPKeyDown;
|
||||
}
|
||||
|
||||
- (void)copy:(id)sender
|
||||
{
|
||||
[super copy:sender];
|
||||
@@ -476,6 +515,16 @@ var kDelegateRespondsTo_textShouldBeginEditing
|
||||
[pasteboard setString:stringForPasting._string forType:CPStringPboardType];
|
||||
[pasteboard setString:_previousSelectionGranularity + '' forType:_CPSmartPboardType];
|
||||
}
|
||||
|
||||
// Without a browser clipboard event, the plain text goes to the system clipboard through the
|
||||
// asynchronous Clipboard API.
|
||||
if (selectedRange.length && ![self _isKeyboardClipboardAction:sender] && _CPTextViewHasAsyncClipboard())
|
||||
{
|
||||
navigator.clipboard.writeText([pasteboard stringForType:CPStringPboardType] || "").then(nil, function(anError)
|
||||
{
|
||||
CPLog.warn("CPTextView: the browser refused to write to the clipboard: " + anError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_pasteString:(id)stringForPasting
|
||||
@@ -519,18 +568,74 @@ var kDelegateRespondsTo_textShouldBeginEditing
|
||||
}
|
||||
- (void)pasteAsPlainText:(id)sender
|
||||
{
|
||||
if (![sender isKindOfClass:_CPNativeInputManager] && [[CPApp currentEvent] type] != CPAppKitDefined)
|
||||
return
|
||||
|
||||
[self _pasteString:[self _plainStringForPasting]];
|
||||
if (sender === _CPNativeInputManager || [sender isKindOfClass:_CPNativeInputManager])
|
||||
[self _pasteString:[self _plainStringForPasting]];
|
||||
else if (![self _isKeyboardClipboardAction:sender])
|
||||
[self _pasteFromSystemClipboardAsPlainText:YES];
|
||||
}
|
||||
|
||||
/*!
|
||||
Pastes the contents of the clipboard at the insertion point or over the selection. Rich text
|
||||
is pasted as rich text in a rich text view. From a key equivalent, the browser's paste event
|
||||
supplies the clipboard. From a menu item or a button, the text view reads the system
|
||||
clipboard through the asynchronous Clipboard API.
|
||||
@param sender the object requesting this
|
||||
*/
|
||||
- (void)paste:(id)sender
|
||||
{
|
||||
if ([[CPApp currentEvent] type] != CPAppKitDefined)
|
||||
return;
|
||||
if (![self _isKeyboardClipboardAction:sender])
|
||||
[self _pasteFromSystemClipboardAsPlainText:NO];
|
||||
}
|
||||
|
||||
[self _pasteString:[self _stringForPasting]];
|
||||
/* @ignore
|
||||
Reads the system clipboard asynchronously and pastes it. Without the Clipboard API, the
|
||||
internal pasteboard is pasted, as before. If the browser refuses access, for example because
|
||||
the user declines, nothing is pasted.
|
||||
*/
|
||||
- (void)_pasteFromSystemClipboardAsPlainText:(BOOL)asPlainText
|
||||
{
|
||||
if (!_CPTextViewHasAsyncClipboard())
|
||||
{
|
||||
[self _pasteString:asPlainText ? [self _plainStringForPasting] : [self _stringForPasting]];
|
||||
return;
|
||||
}
|
||||
|
||||
var textView = self;
|
||||
|
||||
navigator.clipboard.readText().then(function(aString)
|
||||
{
|
||||
[textView _pasteSystemClipboardString:aString asPlainText:asPlainText];
|
||||
|
||||
// The paste happens outside the run loop, so the run loop is run here to close the
|
||||
// undo group and to display the change.
|
||||
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
|
||||
},
|
||||
function(anError)
|
||||
{
|
||||
CPLog.warn("CPTextView: the browser refused to read the clipboard: " + anError);
|
||||
});
|
||||
}
|
||||
|
||||
/* @ignore
|
||||
Pastes aString, read from the system clipboard. When it is the plain text of the internal
|
||||
pasteboard, the clipboard holds what the application copied last, so the internal pasteboard
|
||||
is pasted, with its rich text. Otherwise the clipboard holds text from elsewhere: it replaces
|
||||
the internal pasteboard, so that later pastes agree with it, and is pasted as plain text.
|
||||
*/
|
||||
- (void)_pasteSystemClipboardString:(CPString)aString asPlainText:(BOOL)asPlainText
|
||||
{
|
||||
var pasteboard = [CPPasteboard generalPasteboard];
|
||||
|
||||
if (!_CPTextViewClipboardStringsMatch(aString, [pasteboard stringForType:CPStringPboardType]))
|
||||
{
|
||||
if (!aString)
|
||||
return;
|
||||
|
||||
[pasteboard declareTypes:[CPStringPboardType] owner:nil];
|
||||
[pasteboard setString:aString forType:CPStringPboardType];
|
||||
}
|
||||
|
||||
[self _pasteString:asPlainText ? [self _plainStringForPasting] : [self _stringForPasting]];
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
@@ -1887,7 +1992,8 @@ Sets the selection to a range of characters in response to user action.
|
||||
|
||||
- (void)cut:(id)sender
|
||||
{
|
||||
if ([[CPApp currentEvent] type] != CPAppKitDefined)
|
||||
// From a key equivalent, the browser's cut event copies and deletes the selection.
|
||||
if ([self _isKeyboardClipboardAction:sender])
|
||||
return;
|
||||
|
||||
var selectedRange = [self selectedRange];
|
||||
@@ -4669,6 +4775,17 @@ var _CPCopyPlaceholder = '-';
|
||||
e.preventDefault();
|
||||
var nativeClipboard = (e.originalEvent || e).clipboardData;
|
||||
var currentFirstResponder = [[CPApp keyWindow] firstResponder];
|
||||
var pasteboard = [CPPasteboard generalPasteboard];
|
||||
var nativePlainText = nativeClipboard.getData('text/plain');
|
||||
|
||||
// The internal pasteboard holds what the application copied last. When the system
|
||||
// clipboard holds other text, copied elsewhere, the internal pasteboard is out of date:
|
||||
// it is replaced with that text, so that its rich text is not pasted in its place.
|
||||
if (nativePlainText && !_CPTextViewClipboardStringsMatch(nativePlainText, [pasteboard stringForType:CPStringPboardType]))
|
||||
{
|
||||
[pasteboard declareTypes:[CPStringPboardType] owner:nil];
|
||||
[pasteboard setString:nativePlainText forType:CPStringPboardType];
|
||||
}
|
||||
|
||||
// Can we accept richtext? Then this is our preference (fixme: shift key to force plain text paste)
|
||||
if ([currentFirstResponder respondsToSelector:@selector(isRichText)] && [currentFirstResponder isRichText])
|
||||
@@ -4680,7 +4797,6 @@ var _CPCopyPlaceholder = '-';
|
||||
richtext = [[_CPRTFParser new] parseRTF:richtext];
|
||||
else
|
||||
{
|
||||
var pasteboard = [CPPasteboard generalPasteboard];
|
||||
// If no RTF is available, try to get the internal represatation of richtext from the pasteboard
|
||||
var richData = [pasteboard stringForType:_CPASPboardType];
|
||||
|
||||
@@ -4702,7 +4818,7 @@ var _CPCopyPlaceholder = '-';
|
||||
// If no richtext is available, fall back to plain text
|
||||
}
|
||||
|
||||
var nativeString = nativeClipboard.getData('text/plain');
|
||||
var nativeString = nativePlainText;
|
||||
|
||||
// Use setTimeout to prevent flickering
|
||||
setTimeout(function()
|
||||
|
||||
@@ -227,6 +227,33 @@
|
||||
[self assert:[font cssString] equals:[[[textView typingAttributes] objectForKey:CPFontAttributeName] cssString] message:@"for a selection"];
|
||||
}
|
||||
|
||||
- (void)testPasteOfSystemClipboardText
|
||||
{
|
||||
var font = [CPFont boldSystemFontOfSize:20.0],
|
||||
length = [stringValue length];
|
||||
|
||||
[textView setRichText:YES];
|
||||
[self assertTrue:[theWindow makeFirstResponder:textView]];
|
||||
[[textView textStorage] addAttribute:CPFontAttributeName value:font range:CPMakeRange(0, 2)];
|
||||
|
||||
[textView setSelectedRange:CPMakeRange(0, 2)];
|
||||
[textView copy:nil];
|
||||
|
||||
// The system clipboard still holds the copied text, so the rich text is pasted.
|
||||
[textView setSelectedRange:CPMakeRange(length, 0)];
|
||||
[textView _pasteSystemClipboardString:@"My" asPlainText:NO];
|
||||
|
||||
[self assert:stringValue + @"My" equals:[textView stringValue]];
|
||||
[self assert:[font cssString] equals:[[[textView textStorage] attribute:CPFontAttributeName atIndex:length effectiveRange:nil] cssString]];
|
||||
|
||||
// Text copied elsewhere replaces the internal pasteboard, and is pasted as plain text.
|
||||
[textView _pasteSystemClipboardString:@"other" asPlainText:NO];
|
||||
|
||||
[self assert:stringValue + @"Myother" equals:[textView stringValue]];
|
||||
[self assert:@"other" equals:[[CPPasteboard generalPasteboard] stringForType:CPStringPboardType]];
|
||||
[self assertTrue:([[CPPasteboard generalPasteboard] stringForType:_CPASPboardType] == nil) message:@"the out of date rich text is removed"];
|
||||
}
|
||||
|
||||
- (void)testCopyAndPasteStyle
|
||||
{
|
||||
var font = [CPFont boldSystemFontOfSize:20.0],
|
||||
|
||||
@@ -9,12 +9,6 @@ TextEditor is a document-based test application and a tutorial. When the app use
|
||||
- **The "save changes" alert is modal for the whole app.** `CPDocument` shows it with `runModal` (`CPDocument.j:903`). Cocoa attaches it as a sheet to the document window, so that only that document is blocked.
|
||||
- **Save cannot work without a server.** With no file URL, Save uses `CPSavePanel`, which falls back to `window.prompt` (`CPSavePanel.j:79`). `CPDocument` then sends an HTTP POST. A storage facade is needed, with local (IndexedDB) and remote (HTTP) implementations.
|
||||
|
||||
### Clipboard
|
||||
|
||||
- **Paste and Cut from the Edit menu do nothing.** `-[CPTextView paste:]` (`CPTextView.j:521`) and `cut:` (`:1754`) return unless the current event is `CPAppKitDefined`. Only the browser's native clipboard events, from the keyboard, create that event.
|
||||
- **Copy from the Edit menu cannot reach the system clipboard.** It fills only the internal `CPPasteboard`.
|
||||
- **Fix:** when a menu item calls these methods, use the asynchronous Clipboard API (`navigator.clipboard`). Read RTF first, then plain text. Write plain text, and RTF where the browser allows it. Keep the keyboard path unchanged.
|
||||
|
||||
### Toolbar
|
||||
|
||||
- **The Fonts and Print standard items are not implemented.** `_standardItemWithItemIdentifier:` returns `nil` for both (`_CPToolbarItem.j:641`). AppKit needs `_CPToolbarShowFontsItem` (sends `orderFrontFontPanel:`) and `_CPToolbarPrintItem` (sends `printDocument:`). nib2cib needs matching classes.
|
||||
@@ -50,6 +44,8 @@ How the text system works, for the entries below: `CPTextView` does not use CSS
|
||||
|
||||
## Opportunities for improvement
|
||||
|
||||
- **Rich text through the asynchronous Clipboard API.** Edit > Copy, Cut and Paste chosen from the menu reach the system clipboard through `navigator.clipboard`, which carries plain text only. Rich text still moves between applications through the keyboard path, in the browser's clipboard events, and within the application through the internal pasteboard. Handling attributed text in general is an unbounded task, but a limited subset could be supported, for example through `text/html` in a `ClipboardItem`.
|
||||
|
||||
- **Justification is nominally functional, but its quality is limited.** `CPSimpleTypesetter` breaks lines greedily: it fills each line as far as it fits, as Cocoa does, and then spreads the spare width of each wrapped line evenly over that line's spaces. This naive algorithm does not and cannot produce the quality that modern typesetting expects. It decides each line alone, so word spacing varies widely from line to line, and loose lines are common. Quality needs paragraph-wide line breaking in the manner of Knuth-Plass, as Typst also uses, with language-aware hyphenation. Cappuccino measures and lays out text itself, so this belongs in the typesetter, as its only behaviour, with no setting for users or developers.
|
||||
|
||||
## Planned additions to the app
|
||||
@@ -86,4 +82,5 @@ How the text system works, for the entries below: `CPTextView` does not use CSS
|
||||
- Format > List: bulleted and numbered lists, applied to one or several paragraphs and removed with None. Return starts the next item and keeps the item's text style; Return in an empty item ends the list. Tab at the start of an item nests it, and Shift-Tab returns it. Delete at the start of an item's text removes it from the list. The items are numbered again after each change.
|
||||
- Format > Text > Copy Ruler and Paste Ruler with lists: pasting adds or removes list markers, on one or several paragraphs, and a pasted item joins the neighbouring list.
|
||||
- Typing at the end of styled text, and the font panel, use the style of the character before the insertion point.
|
||||
- Edit > Copy, Cut and Paste from the menu: plain text moves to and from other applications through the system clipboard; text copied and pasted within the application keeps its style. Command-V pastes text copied in another application, not an earlier copy within the application.
|
||||
- Builds with both the legacy toolchain and capp-build.
|
||||
Reference in new issue
Block a user