Files
cappuccino/AppKit/CPTextView/CPTextAttachment.j
T
David Richardson df78974183 AppKit: Add CPTextAttachment and image support to CPTextView
AppKit lacked a public text attachment class, preventing rich text documents
from embedding or inserting images.

* AppKit/CPTextView/CPTextAttachment.j:
  - Implement CPTextAttachment (modelled on NSTextAttachment) supporting
    initialization via data and file type, contents, file types, images,
    bounds, and keyed archiving.
  - Adopt a TextKit 2-style view rendering architecture that scales images
    to fit text container width and triggers layout updates upon asynchronous loading.

* AppKit/CPTextView/CPTextStorage.j:
  - Update attributedStringWithAttachment: to accept CPTextAttachment instances
    alongside direct views, routing rendering through existing attachment pathways.

* AppKit/CPTextView/CPTextView.j:
  - Implement the insertImage: action (invoking browser file pickers) and
    Command-V paste handling for images.
  - Configure inserted attachments with responsive view instances scaled to
    text width, omitting live views during pasteboard archiving.

* AppKit/CPTextView/CPTextContainer.j:
  - Adjust attachment bounds to match updated container widths during layout invalidation.

* AppKit/AppKit.j:
  - Export CPTextAttachment.

* Tests/AppKit/CPTextAttachmentTest.j:
  - Add test coverage for data URLs, archiving, width scaling, insertion,
    and serialized text output.

* Tests/Manual/TextEditor/Resources/MainMenu.xib, MainMenu.cib:
  - Add Edit > Insert Image… menu item.

* Tests/Manual/TextEditor/WORKLIST.md:
  - Mark image insertion testing as completed; log remaining image tasks as open.

Rich text documents now support embedded images inserted via file selection or clipboard paste.
2026-10-24 12:34:51 -06:00

368 lines
9.5 KiB
Plaintext

/*
* CPTextAttachment.j
* AppKit
*
* Created by David Richardson on 25/10/2026.
* Copyright David Richardson 2026.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import <Foundation/CPObject.j>
@import <Foundation/CPData.j>
@import <Foundation/CPNotificationCenter.j>
@import "CPImage.j"
@import "CPImageView.j"
/*
Posted when an attachment's displayed size changes, for example when its image has loaded.
A text view that contains the attachment lays its text out again.
*/
_CPTextAttachmentDidChangeSizeNotification = @"_CPTextAttachmentDidChangeSizeNotification";
/*!
@ingroup appkit
@class CPTextAttachment
A CPTextAttachment holds an image in rich text, as NSTextAttachment does in Cocoa. The text
holds the attachment as one attachment character (CPAttachmentCharacter), whose
CPAttachmentAttributeName attribute is the attachment. The attachment is edited, copied and
undone as that one character.
The attachment keeps the image's data and type, and shows the image in a view. As in TextKit
2, an attachment is drawn by a view, not by a cell. The text system lays the view out as the
character, at the view's size, with its bottom on the baseline. That size is the attachment's
bounds, or the image's natural size, scaled down to fit the width of the text, as in TextEdit.
*/
@implementation CPTextAttachment : CPObject
{
CPData _contents;
CPString _fileType;
CPImage _image;
CGRect _bounds;
CPImageView _imageView;
float _maximumWidth;
}
/*!
Initialises an attachment with image data.
@param contentData the image's data
@param aType the image's type, as a MIME type such as \c @"image/png", or a uniform type
identifier such as \c @"public.png"
*/
- (id)initWithData:(CPData)contentData ofType:(CPString)aType
{
if (self = [super init])
{
_contents = contentData;
_fileType = aType;
_bounds = CGRectMakeZero();
_maximumWidth = 0.0;
}
return self;
}
- (id)init
{
return [self initWithData:nil ofType:nil];
}
/* @ignore
Returns an attachment made from a data URL, such as the browser's FileReader produces.
*/
+ (CPTextAttachment)_attachmentWithDataURL:(CPString)aDataURL
{
var match = /^data:([^;,]+)(;base64)?,(.*)$/.exec(aDataURL || "");
if (!match)
return nil;
var data = match[2] ? [CPData dataWithBase64:match[3]] : [CPData dataWithRawString:decodeURIComponent(match[3])];
return [[self alloc] initWithData:data ofType:match[1]];
}
/*!
Returns the image's data.
*/
- (CPData)contents
{
return _contents;
}
/*!
Sets the image's data. The image is made again from the data.
*/
- (void)setContents:(CPData)contentData
{
_contents = contentData;
[self _setImage:nil];
}
/*!
Returns the image's type.
*/
- (CPString)fileType
{
return _fileType;
}
/*!
Sets the image's type.
*/
- (void)setFileType:(CPString)aType
{
_fileType = aType;
}
/*!
Returns the image. It is made from the attachment's data the first time it is needed.
*/
- (CPImage)image
{
if (!_image && _contents)
[self _setImage:[[CPImage alloc] initWithContentsOfFile:[self _dataURL]]];
return _image;
}
/*!
Sets the image. An image set this way has no data of its own unless it was made from a data
URL, so an attachment that must be archived or copied should be made with
-initWithData:ofType:.
*/
- (void)setImage:(CPImage)anImage
{
[self _setImage:anImage];
}
/*!
Returns the attachment's bounds in the text. With a zero size, the attachment takes the
image's natural size.
*/
- (CGRect)bounds
{
return CGRectMakeCopy(_bounds);
}
/*!
Sets the attachment's bounds in the text.
*/
- (void)setBounds:(CGRect)aRect
{
_bounds = CGRectMakeCopy(aRect);
[self _updateViewSize];
}
// MARK: Display
/* @ignore
Returns the view that shows the attachment in the text. The text system lays it out as the
attachment character.
*/
- (CPView)_attachmentView
{
if (!_imageView)
{
_imageView = [[CPImageView alloc] initWithFrame:CGRectMakeZero()];
[_imageView setImageScaling:CPImageScaleAxesIndependently];
[_imageView setImage:[self image]];
[self _updateViewSize];
}
return _imageView;
}
/* @ignore
Sets the widest the attachment may be shown, which is the width of the text. Returns YES if
the displayed size changes.
*/
- (BOOL)_setMaximumWidth:(float)aWidth
{
if (_maximumWidth === aWidth)
return NO;
_maximumWidth = aWidth;
return [self _updateViewSize];
}
/* @ignore
Returns the size at which the attachment is shown: its bounds, or the image's natural size,
scaled down in proportion to fit the maximum width. The size is zero until the image has
loaded.
*/
- (CGSize)_displaySize
{
var size = CGSizeMakeCopy(_bounds.size);
if (!size.width || !size.height)
{
var image = [self image];
if (!image || [image loadStatus] !== CPImageLoadStatusCompleted)
return CGSizeMakeZero();
size = CGSizeMakeCopy([image size]);
}
if (_maximumWidth > 0.0 && size.width > _maximumWidth)
{
size.height = ROUND(size.height * _maximumWidth / size.width);
size.width = _maximumWidth;
}
return size;
}
/* @ignore
Gives the view the displayed size. Returns YES if the size changes.
*/
- (BOOL)_updateViewSize
{
if (!_imageView)
return NO;
var size = [self _displaySize];
if (CGSizeEqualToSize(size, [_imageView frameSize]))
return NO;
[_imageView setFrameSize:size];
return YES;
}
/* @ignore
Sets the image, and waits for it to load when it has not yet loaded.
*/
- (void)_setImage:(CPImage)anImage
{
if (_image)
[[CPNotificationCenter defaultCenter] removeObserver:self name:CPImageDidLoadNotification object:_image];
_image = anImage;
if (_image && [_image loadStatus] !== CPImageLoadStatusCompleted)
{
[[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(_imageDidLoad:) name:CPImageDidLoadNotification object:_image];
[_image load];
}
if (_imageView)
{
[_imageView setImage:_image];
if ([self _updateViewSize])
[[CPNotificationCenter defaultCenter] postNotificationName:_CPTextAttachmentDidChangeSizeNotification object:self];
}
}
/* @ignore
The image's natural size is known once it has loaded, so the view takes its size, and the
text that contains the attachment is laid out again.
*/
- (void)_imageDidLoad:(CPNotification)aNotification
{
[[CPNotificationCenter defaultCenter] removeObserver:self name:CPImageDidLoadNotification object:_image];
if (_imageView)
[_imageView setImage:_image];
if ([self _updateViewSize])
[[CPNotificationCenter defaultCenter] postNotificationName:_CPTextAttachmentDidChangeSizeNotification object:self];
}
/* @ignore
Returns the attachment's data as a data URL, which the browser loads as an image.
*/
- (CPString)_dataURL
{
return "data:" + [self _MIMEType] + ";base64," + [_contents base64];
}
/* @ignore
Returns the image's MIME type. A uniform type identifier is mapped to its MIME type.
*/
- (CPString)_MIMEType
{
var type = _fileType || @"image/png";
if (type.indexOf("/") !== -1)
return type;
switch (type)
{
case @"public.jpeg":
return @"image/jpeg";
case @"com.compuserve.gif":
return @"image/gif";
case @"public.svg-image":
return @"image/svg+xml";
case @"org.webmproject.webp":
return @"image/webp";
default:
return @"image/png";
}
}
@end
var CPTextAttachmentContentsKey = @"CPTextAttachmentContents",
CPTextAttachmentFileTypeKey = @"CPTextAttachmentFileType",
CPTextAttachmentBoundsKey = @"CPTextAttachmentBounds";
@implementation CPTextAttachment (CPCoding)
/*!
Decodes the attachment. The image is made again from the archived data.
*/
- (id)initWithCoder:(CPCoder)aCoder
{
var contentsString = [aCoder decodeObjectForKey:CPTextAttachmentContentsKey];
self = [self initWithData:contentsString ? [CPData dataWithBase64:contentsString] : nil
ofType:[aCoder decodeObjectForKey:CPTextAttachmentFileTypeKey]];
if (self && [aCoder containsValueForKey:CPTextAttachmentBoundsKey])
_bounds = [aCoder decodeRectForKey:CPTextAttachmentBoundsKey];
return self;
}
/*!
Encodes the attachment's data, type and bounds. The view is not encoded.
*/
- (void)encodeWithCoder:(CPCoder)aCoder
{
if (_contents)
[aCoder encodeObject:[_contents base64] forKey:CPTextAttachmentContentsKey];
if (_fileType)
[aCoder encodeObject:_fileType forKey:CPTextAttachmentFileTypeKey];
if (!CGRectIsEmpty(_bounds))
[aCoder encodeRect:_bounds forKey:CPTextAttachmentBoundsKey];
}
@end