mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-04-25 17:25:08 +02:00
This page renders the bookmarks as a tree and hook context menu events up to the UI's bookmarks bar context menus to allow editing bookmarks. Users can also drag-and-drop bookmark items around.
440 lines
16 KiB
Plaintext
440 lines
16 KiB
Plaintext
/*
|
|
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <LibWebView/Application.h>
|
|
#include <LibWebView/BookmarkStore.h>
|
|
#include <LibWebView/Menu.h>
|
|
|
|
#import <Interface/BookmarkFolder.h>
|
|
#import <Interface/BookmarksBar.h>
|
|
#import <Interface/Event.h>
|
|
#import <Interface/Menu.h>
|
|
#import <Utilities/Conversions.h>
|
|
|
|
#if !__has_feature(objc_arc)
|
|
# error "This project requires ARC"
|
|
#endif
|
|
|
|
static constexpr CGFloat const BOOKMARK_BUTTON_MAX_WIDTH = 150;
|
|
static constexpr CGFloat const BOOKMARK_ITEM_SPACING = 2;
|
|
static constexpr CGFloat const BOOKMARK_LEADING_INSET = 8;
|
|
static constexpr CGFloat const OVERFLOW_TRAILING_INSET = 4;
|
|
|
|
static Optional<WebView::Menu&> find_bookmark_folder_by_id(WebView::Menu& menu, StringView id)
|
|
{
|
|
for (auto& item : menu.items()) {
|
|
auto* submenu_ptr = item.get_pointer<NonnullRefPtr<WebView::Menu>>();
|
|
if (!submenu_ptr)
|
|
continue;
|
|
|
|
auto& submenu = **submenu_ptr;
|
|
|
|
if (auto submenu_id = submenu.properties().get("id"sv); submenu_id.has_value() && *submenu_id == id)
|
|
return submenu;
|
|
|
|
if (auto descendant = find_bookmark_folder_by_id(submenu, id); descendant.has_value())
|
|
return descendant;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@interface BookmarksBar ()
|
|
|
|
@property (nonatomic, strong) NSStackView* bookmark_items;
|
|
@property (nonatomic, strong) BookmarkFolderPopover* bookmark_folder_popover;
|
|
@property (nonatomic, weak) NSButton* active_bookmark_folder_button;
|
|
|
|
@property (nonatomic, strong) NSButton* overflow_button;
|
|
@property (nonatomic, strong) NSMenu* overflow_menu;
|
|
|
|
@property (nonatomic, strong) NSMenu* bookmarks_bar_context_menu;
|
|
@property (nonatomic, strong) NSMenu* bookmark_context_menu;
|
|
@property (nonatomic, strong) NSMenu* bookmark_folder_context_menu;
|
|
|
|
@property (nonatomic, strong, readwrite) NSString* selected_bookmark_menu_item_id;
|
|
@property (nonatomic, strong, readwrite) NSString* selected_bookmark_menu_target_folder_id;
|
|
|
|
@end
|
|
|
|
@implementation BookmarksBar
|
|
|
|
@synthesize overflow_menu = _overflow_menu;
|
|
@synthesize bookmarks_bar_context_menu = _bookmarks_bar_context_menu;
|
|
@synthesize bookmark_context_menu = _bookmark_context_menu;
|
|
@synthesize bookmark_folder_context_menu = _bookmark_folder_context_menu;
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super init]) {
|
|
self.bookmark_items = [[NSStackView alloc] init];
|
|
[self.bookmark_items setOrientation:NSUserInterfaceLayoutOrientationHorizontal];
|
|
[self.bookmark_items setSpacing:BOOKMARK_ITEM_SPACING];
|
|
[self.bookmark_items setEdgeInsets:NSEdgeInsets { 0, BOOKMARK_LEADING_INSET, 0, 0 }];
|
|
[self.bookmark_items setAlignment:NSLayoutAttributeCenterY];
|
|
[self.bookmark_items setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
|
|
[self.bookmark_items setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
[self.bookmark_items setClippingResistancePriority:NSLayoutPriorityDefaultLow
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
|
|
self.overflow_button = [NSButton buttonWithImage:[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:@""]
|
|
target:self
|
|
action:@selector(openOverflowMenu:)];
|
|
[self.overflow_button setBezelStyle:NSBezelStyleAccessoryBarAction];
|
|
[self.overflow_button setShowsBorderOnlyWhileMouseInside:YES];
|
|
[self.overflow_button setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
[self.overflow_button setHidden:YES];
|
|
|
|
[self.overflow_button setContentCompressionResistancePriority:NSLayoutPriorityRequired
|
|
forOrientation:NSLayoutConstraintOrientationHorizontal];
|
|
|
|
[self addSubview:self.bookmark_items];
|
|
[self addSubview:self.overflow_button];
|
|
|
|
[NSLayoutConstraint activateConstraints:@[
|
|
[[self.bookmark_items leadingAnchor] constraintEqualToAnchor:[self leadingAnchor]],
|
|
[[self.bookmark_items topAnchor] constraintEqualToAnchor:[self topAnchor]],
|
|
[[self.bookmark_items bottomAnchor] constraintEqualToAnchor:[self bottomAnchor]],
|
|
|
|
[[self.overflow_button trailingAnchor] constraintEqualToAnchor:[self trailingAnchor]
|
|
constant:-OVERFLOW_TRAILING_INSET],
|
|
[[self.overflow_button centerYAnchor] constraintEqualToAnchor:[self centerYAnchor]],
|
|
|
|
[[self.bookmark_items trailingAnchor] constraintLessThanOrEqualToAnchor:[self.overflow_button leadingAnchor]
|
|
constant:-BOOKMARK_ITEM_SPACING],
|
|
]];
|
|
|
|
[self setClipsToBounds:YES];
|
|
|
|
[self rebuild];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)rebuild
|
|
{
|
|
[self closeBookmarkFolders];
|
|
[self.bookmark_items setSubviews:@[]];
|
|
|
|
auto set_button_properties = [](NSButton* button, StringView title) {
|
|
[button setTitle:Ladybird::string_to_ns_string(title)];
|
|
[button setImagePosition:NSImageLeading];
|
|
|
|
[button setBezelStyle:NSBezelStyleAccessoryBarAction];
|
|
[button setShowsBorderOnlyWhileMouseInside:YES];
|
|
|
|
[button setFont:[NSFont systemFontOfSize:12]];
|
|
[button setControlSize:NSControlSizeRegular];
|
|
|
|
[[button cell] setLineBreakMode:NSLineBreakByTruncatingTail];
|
|
[[button widthAnchor] constraintLessThanOrEqualToConstant:BOOKMARK_BUTTON_MAX_WIDTH].active = YES;
|
|
};
|
|
|
|
for (auto const& item : WebView::Application::the().bookmarks_menu().items()) {
|
|
auto* button = item.visit(
|
|
[&](NonnullRefPtr<WebView::Action> const& bookmark) -> NSButton* {
|
|
if (bookmark->id() != WebView::ActionID::BookmarkItem)
|
|
return nil;
|
|
|
|
auto* button = Ladybird::create_application_button(bookmark);
|
|
set_button_properties(button, bookmark->text());
|
|
|
|
return button;
|
|
},
|
|
[&](NonnullRefPtr<WebView::Menu> const& folder) -> NSButton* {
|
|
auto* button = [NSButton buttonWithImage:[NSImage imageWithSystemSymbolName:@"folder" accessibilityDescription:@""]
|
|
target:self
|
|
action:@selector(openFolder:)];
|
|
set_button_properties(button, folder->title());
|
|
|
|
Ladybird::add_control_properties(button, *folder);
|
|
return button;
|
|
},
|
|
[](WebView::Separator) -> NSButton* {
|
|
return nil;
|
|
});
|
|
|
|
if (button) {
|
|
[self.bookmark_items addView:button inGravity:NSStackViewGravityLeading];
|
|
}
|
|
}
|
|
|
|
[self setNeedsLayout:YES];
|
|
}
|
|
|
|
- (NSMenu*)overflow_menu
|
|
{
|
|
if (!_overflow_menu) {
|
|
_overflow_menu = [[NSMenu alloc] init];
|
|
|
|
NSArray<NSView*>* buttons = [self.bookmark_items views];
|
|
size_t button_index = 0;
|
|
|
|
for (auto const& item : WebView::Application::the().bookmarks_menu().items()) {
|
|
auto is_bookmark_item = item.visit(
|
|
[](NonnullRefPtr<WebView::Action> const& action) { return action->id() == WebView::ActionID::BookmarkItem; },
|
|
[](NonnullRefPtr<WebView::Menu> const&) { return true; },
|
|
[](WebView::Separator) { return false; });
|
|
|
|
if (!is_bookmark_item)
|
|
continue;
|
|
|
|
if (button_index < [buttons count] && [buttons[button_index] isHidden]) {
|
|
item.visit(
|
|
[&](NonnullRefPtr<WebView::Action> const& action) {
|
|
[_overflow_menu addItem:Ladybird::create_application_menu_item(action)];
|
|
},
|
|
[&](NonnullRefPtr<WebView::Menu> const& folder) {
|
|
auto* folder_item = [[NSMenuItem alloc] initWithTitle:Ladybird::string_to_ns_string(folder->title())
|
|
action:nil
|
|
keyEquivalent:@""];
|
|
|
|
auto* submenu = Ladybird::create_application_menu(folder);
|
|
[folder_item setSubmenu:submenu];
|
|
|
|
[_overflow_menu addItem:folder_item];
|
|
},
|
|
[](WebView::Separator) {});
|
|
}
|
|
|
|
++button_index;
|
|
}
|
|
}
|
|
|
|
return _overflow_menu;
|
|
}
|
|
|
|
- (void)openOverflowMenu:(NSButton*)sender
|
|
{
|
|
if ([self.overflow_menu numberOfItems] > 0) {
|
|
[self.overflow_menu popUpMenuPositioningItem:nil
|
|
atLocation:NSMakePoint(0, [sender bounds].size.height)
|
|
inView:sender];
|
|
}
|
|
}
|
|
|
|
- (void)openFolder:(NSButton*)sender
|
|
{
|
|
auto* item_id = Ladybird::get_control_property(sender, @"id");
|
|
if (!item_id)
|
|
return;
|
|
|
|
auto id = Ladybird::ns_string_to_string(item_id);
|
|
auto folder = find_bookmark_folder_by_id(WebView::Application::the().bookmarks_menu(), id);
|
|
if (!folder.has_value())
|
|
return;
|
|
|
|
[self openFolderMenu:*folder anchoredToView:sender preferredEdge:NSRectEdgeMaxY];
|
|
}
|
|
|
|
- (void)openFolderMenu:(WebView::Menu&)menu
|
|
anchoredToView:(NSView*)view
|
|
preferredEdge:(NSRectEdge)preferredEdge
|
|
{
|
|
if (menu.size() == 0)
|
|
return;
|
|
|
|
[self closeBookmarkFolders];
|
|
|
|
if ([view isKindOfClass:[NSButton class]]) {
|
|
self.active_bookmark_folder_button = (NSButton*)view;
|
|
[self.active_bookmark_folder_button setShowsBorderOnlyWhileMouseInside:NO];
|
|
[self.active_bookmark_folder_button highlight:YES];
|
|
}
|
|
|
|
self.bookmark_folder_popover = [[BookmarkFolderPopover alloc] init:menu bookmarksBar:self parentFolder:nil];
|
|
[self.bookmark_folder_popover showRelativeToView:view preferredEdge:preferredEdge];
|
|
}
|
|
|
|
- (void)closeBookmarkFolders
|
|
{
|
|
[self.bookmark_folder_popover close];
|
|
self.bookmark_folder_popover = nil;
|
|
|
|
[self clearActiveBookmarkFolder];
|
|
}
|
|
|
|
- (void)bookmarkFolderDidClose:(BookmarkFolderPopover*)folder
|
|
{
|
|
if (self.bookmark_folder_popover == folder)
|
|
self.bookmark_folder_popover = nil;
|
|
|
|
[self clearActiveBookmarkFolder];
|
|
}
|
|
|
|
- (void)clearActiveBookmarkFolder
|
|
{
|
|
if (!self.active_bookmark_folder_button)
|
|
return;
|
|
|
|
[self.active_bookmark_folder_button highlight:NO];
|
|
[self.active_bookmark_folder_button setShowsBorderOnlyWhileMouseInside:YES];
|
|
self.active_bookmark_folder_button = nil;
|
|
}
|
|
|
|
- (void)showContextMenu:(id)control event:(NSEvent*)event
|
|
{
|
|
self.selected_bookmark_menu_item_id = Ladybird::get_control_property(control, @"id");
|
|
self.selected_bookmark_menu_target_folder_id = Ladybird::get_control_property(control, @"target_folder_id");
|
|
|
|
if (auto* type = Ladybird::get_control_property(control, @"type"); [type isEqualToString:@"bookmark"])
|
|
[NSMenu popUpContextMenu:self.bookmark_context_menu withEvent:event forView:control];
|
|
else if ([type isEqualToString:@"folder"])
|
|
[NSMenu popUpContextMenu:self.bookmark_folder_context_menu withEvent:event forView:control];
|
|
}
|
|
|
|
- (void)showContextMenu:(Gfx::IntPoint)content_position
|
|
view:(NSView*)view
|
|
bookmarkItem:(Optional<WebView::BookmarkItem const&>)item
|
|
targetFolderID:(Optional<String const&>)target_folder_id
|
|
{
|
|
auto* event = Ladybird::create_context_menu_mouse_event(view, content_position);
|
|
|
|
if (item.has_value()) {
|
|
self.selected_bookmark_menu_item_id = Ladybird::string_to_ns_string(item->id);
|
|
self.selected_bookmark_menu_target_folder_id = target_folder_id.has_value()
|
|
? Ladybird::string_to_ns_string(*target_folder_id)
|
|
: nil;
|
|
|
|
if (item->is_bookmark())
|
|
[NSMenu popUpContextMenu:self.bookmark_context_menu withEvent:event forView:view];
|
|
else if (item->is_folder())
|
|
[NSMenu popUpContextMenu:self.bookmark_folder_context_menu withEvent:event forView:view];
|
|
} else {
|
|
self.selected_bookmark_menu_item_id = @"";
|
|
self.selected_bookmark_menu_target_folder_id = nil;
|
|
|
|
[NSMenu popUpContextMenu:self.bookmarks_bar_context_menu withEvent:event forView:view];
|
|
}
|
|
}
|
|
|
|
- (void)showContextMenuForEvent:(NSEvent*)event
|
|
{
|
|
if (auto* button = [self bookmarkButtonForEvent:event]) {
|
|
[self showContextMenu:button event:event];
|
|
return;
|
|
}
|
|
|
|
self.selected_bookmark_menu_item_id = @"";
|
|
self.selected_bookmark_menu_target_folder_id = nil;
|
|
|
|
[NSMenu popUpContextMenu:self.bookmarks_bar_context_menu withEvent:event forView:self];
|
|
}
|
|
|
|
- (NSView*)bookmarkButtonForEvent:(NSEvent*)event
|
|
{
|
|
auto location = [event locationInWindow];
|
|
|
|
for (NSView* button in [self.bookmark_items views]) {
|
|
if ([button isHidden])
|
|
continue;
|
|
|
|
auto point = [button convertPoint:location fromView:nil];
|
|
if (!NSPointInRect(point, [button bounds]))
|
|
continue;
|
|
|
|
return button;
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
- (void)rightMouseDown:(NSEvent*)event
|
|
{
|
|
[self showContextMenuForEvent:event];
|
|
}
|
|
|
|
- (void)mouseDown:(NSEvent*)event
|
|
{
|
|
if ([event modifierFlags] & NSEventModifierFlagControl) {
|
|
[self showContextMenuForEvent:event];
|
|
return;
|
|
}
|
|
|
|
if ([event modifierFlags] & NSEventModifierFlagCommand) {
|
|
if (auto* button = [self bookmarkButtonForEvent:event]) {
|
|
if (auto* type = Ladybird::get_control_property(button, @"type"); [type isEqualToString:@"bookmark"]) {
|
|
auto bookmark_id = Ladybird::ns_string_to_string(Ladybird::get_control_property(button, @"id"));
|
|
auto activate_tab = ([event modifierFlags] & NSEventModifierFlagShift) ? Web::HTML::ActivateTab::No : Web::HTML::ActivateTab::Yes;
|
|
|
|
WebView::Application::the().open_bookmark_in_new_tab(bookmark_id, activate_tab);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
[super mouseDown:event];
|
|
}
|
|
|
|
- (nullable NSView*)hitTest:(NSPoint)point
|
|
{
|
|
auto* hit = [super hitTest:point];
|
|
if (!hit)
|
|
return nil;
|
|
|
|
// Route cmd and ctrl+left clicks to the BookmarksBar so we can handle the action appropriately, rather than letting
|
|
// NSButton swallow the event for its own click tracking.
|
|
auto* event = [NSApp currentEvent];
|
|
if ([event type] == NSEventTypeLeftMouseDown && ([event modifierFlags] & (NSEventModifierFlagControl | NSEventModifierFlagCommand)))
|
|
return self;
|
|
|
|
return hit;
|
|
}
|
|
|
|
- (NSMenu*)bookmarks_bar_context_menu
|
|
{
|
|
if (!_bookmarks_bar_context_menu)
|
|
_bookmarks_bar_context_menu = Ladybird::create_application_menu(WebView::Application::the().bookmarks_bar_context_menu());
|
|
return _bookmarks_bar_context_menu;
|
|
}
|
|
|
|
- (NSMenu*)bookmark_context_menu
|
|
{
|
|
if (!_bookmark_context_menu)
|
|
_bookmark_context_menu = Ladybird::create_application_menu(WebView::Application::the().bookmark_context_menu());
|
|
return _bookmark_context_menu;
|
|
}
|
|
|
|
- (NSMenu*)bookmark_folder_context_menu
|
|
{
|
|
if (!_bookmark_folder_context_menu)
|
|
_bookmark_folder_context_menu = Ladybird::create_application_menu(WebView::Application::the().bookmark_folder_context_menu());
|
|
return _bookmark_folder_context_menu;
|
|
}
|
|
|
|
- (void)layout
|
|
{
|
|
[super layout];
|
|
|
|
auto overflow_width = [self.overflow_button fittingSize].width + OVERFLOW_TRAILING_INSET + BOOKMARK_ITEM_SPACING;
|
|
auto total_width = [self bounds].size.width;
|
|
|
|
// First pass: check if any buttons overflow without reserving space for the overflow button.
|
|
auto used_width = BOOKMARK_LEADING_INSET;
|
|
for (NSView* button in [self.bookmark_items views]) {
|
|
used_width += [button fittingSize].width + BOOKMARK_ITEM_SPACING;
|
|
}
|
|
|
|
auto has_overflow = used_width > total_width;
|
|
auto available_width = has_overflow ? total_width - overflow_width : total_width;
|
|
|
|
// Second pass: hide buttons that don't fully fit with overflow button.
|
|
used_width = BOOKMARK_LEADING_INSET;
|
|
for (NSView* button in [self.bookmark_items views]) {
|
|
used_width += [button fittingSize].width + BOOKMARK_ITEM_SPACING;
|
|
[button setHidden:(used_width > available_width)];
|
|
}
|
|
|
|
[self.overflow_button setHidden:!has_overflow];
|
|
self.overflow_menu = nil;
|
|
}
|
|
|
|
@end
|