blob: 94fc9701db264e64e1d6f8d2bec5ef7bf222ad02 [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "pdf/pdf_ink_module.h"
#include <stddef.h>
#include <algorithm>
#include <limits>
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <utility>
#include <variant>
#include <vector>
#include "base/check.h"
#include "base/containers/fixed_flat_map.h"
#include "base/feature_list.h"
#include "base/notreached.h"
#include "base/numerics/safe_conversions.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "base/values.h"
#include "pdf/draw_utils/page_boundary_intersect.h"
#include "pdf/input_utils.h"
#include "pdf/message_util.h"
#include "pdf/page_orientation.h"
#include "pdf/pdf_features.h"
#include "pdf/pdf_ink_brush.h"
#include "pdf/pdf_ink_conversions.h"
#include "pdf/pdf_ink_cursor.h"
#include "pdf/pdf_ink_metrics_handler.h"
#include "pdf/pdf_ink_module_client.h"
#include "pdf/pdf_ink_transform.h"
#include "third_party/blink/public/common/input/web_input_event.h"
#include "third_party/blink/public/common/input/web_mouse_event.h"
#include "third_party/blink/public/common/input/web_pointer_properties.h"
#include "third_party/blink/public/common/input/web_touch_event.h"
#include "third_party/blink/public/common/input/web_touch_point.h"
#include "third_party/ink/src/ink/brush/brush.h"
#include "third_party/ink/src/ink/geometry/affine_transform.h"
#include "third_party/ink/src/ink/geometry/intersects.h"
#include "third_party/ink/src/ink/geometry/partitioned_mesh.h"
#include "third_party/ink/src/ink/geometry/rect.h"
#include "third_party/ink/src/ink/rendering/skia/native/skia_renderer.h"
#include "third_party/ink/src/ink/strokes/in_progress_stroke.h"
#include "third_party/ink/src/ink/strokes/input/stroke_input.h"
#include "third_party/ink/src/ink/strokes/input/stroke_input_batch.h"
#include "third_party/ink/src/ink/strokes/stroke.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "third_party/skia/include/core/SkCanvas.h"
#include "third_party/skia/include/core/SkColor.h"
#include "ui/base/cursor/cursor.h"
#include "ui/base/cursor/mojom/cursor_type.mojom.h"
#include "ui/events/event_constants.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/point_conversions.h"
#include "ui/gfx/geometry/point_f.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/rect_f.h"
#include "ui/gfx/geometry/skia_conversions.h"
#include "ui/gfx/geometry/vector2d_f.h"
namespace chrome_pdf {
namespace {
constexpr ink::AffineTransform kIdentityTransform;
constexpr SkColor kEraserColor = SK_ColorWHITE;
constexpr int kEraserSize = 3;
// `is_ink` represents the Ink thumbnail when true, and the PDF thumbnail when
// false.
base::Value::Dict CreateUpdateThumbnailMessage(
int page_index,
bool is_ink,
std::vector<uint8_t> image_data,
const gfx::Size& thumbnail_size) {
return base::Value::Dict()
.Set("type", "updateInk2Thumbnail")
.Set("pageNumber", page_index + 1)
.Set("isInk", is_ink)
.Set("imageData", std::move(image_data))
.Set("width", thumbnail_size.width())
.Set("height", thumbnail_size.height());
}
ink::StrokeInput::ToolType GetToolTypeFromTouchEvent(
const blink::WebTouchEvent& event) {
// Assumes the caller already handled multi-touch events.
CHECK_EQ(event.touches_length, 1u);
return event.touches[0].pointer_type ==
blink::WebPointerProperties::PointerType::kPen
? ink::StrokeInput::ToolType::kStylus
: ink::StrokeInput::ToolType::kTouch;
}
PdfInkBrush CreateDefaultHighlighterBrush() {
return PdfInkBrush(PdfInkBrush::Type::kHighlighter,
SkColorSetRGB(0xF2, 0x8B, 0x82),
/*size=*/8.0f);
}
PdfInkBrush CreateDefaultPenBrush() {
return PdfInkBrush(PdfInkBrush::Type::kPen, SK_ColorBLACK, /*size=*/3.0f);
}
// Check if `color` is a valid color value within range.
void CheckColorIsWithinRange(int color) {
CHECK_GE(color, 0);
CHECK_LE(color, 255);
}
ink::Rect GetEraserRect(const gfx::PointF& center) {
return ink::Rect::FromTwoPoints(
{center.x() - kEraserSize, center.y() - kEraserSize},
{center.x() + kEraserSize, center.y() + kEraserSize});
}
SkRect GetDrawPageClipRect(const gfx::Rect& content_rect,
const gfx::Vector2dF& origin_offset) {
gfx::RectF clip_rect(content_rect);
clip_rect.Offset(origin_offset);
return gfx::RectFToSkRect(clip_rect);
}
blink::WebMouseEvent GenerateLeftMouseUpEvent(const gfx::PointF& position,
base::TimeTicks timestamp) {
return blink::WebMouseEvent(
blink::WebInputEvent::Type::kMouseUp,
/*position=*/position,
/*global_position=*/position, blink::WebPointerProperties::Button::kLeft,
/*click_count_param=*/1, blink::WebInputEvent::Modifiers::kNoModifiers,
timestamp);
}
} // namespace
PdfInkModule::PdfInkModule(PdfInkModuleClient& client)
: client_(client),
highlighter_brush_(CreateDefaultHighlighterBrush()),
pen_brush_(CreateDefaultPenBrush()) {
CHECK(base::FeatureList::IsEnabled(features::kPdfInk2));
CHECK(is_drawing_stroke());
// Default to a pen brush.
drawing_stroke_state().brush_type = PdfInkBrush::Type::kPen;
}
PdfInkModule::~PdfInkModule() = default;
bool PdfInkModule::ShouldBlockTextSelectionChanged() {
return features::kPdfInk2TextHighlighting.Get() && is_text_highlighting();
}
bool PdfInkModule::HasInputsToDraw() const {
if (mode_ != InkAnnotationMode::kDraw || is_erasing_stroke()) {
return false;
}
if (is_text_highlighting()) {
return !text_highlight_state().highlight_strokes.empty();
}
CHECK(is_drawing_stroke());
return !drawing_stroke_state().inputs.empty();
}
void PdfInkModule::Draw(SkCanvas& canvas) {
ink::SkiaRenderer skia_renderer;
if (is_text_highlighting()) {
const auto& highlight_strokes = text_highlight_state().highlight_strokes;
CHECK(!highlight_strokes.empty());
for (const auto& [page_index, strokes] : highlight_strokes) {
SkAutoCanvasRestore save_restore(&canvas, /*doSave=*/true);
const auto [transform, clip_rect] = GetTransformAndClipRect(page_index);
canvas.clipRect(clip_rect);
for (const auto& stroke : strokes) {
auto status = skia_renderer.Draw(nullptr, stroke, transform, canvas);
CHECK(status.ok());
}
}
return;
}
CHECK(is_drawing_stroke());
auto in_progress_stroke = CreateInProgressStrokeSegmentsFromInputs();
CHECK(!in_progress_stroke.empty());
SkAutoCanvasRestore save_restore(&canvas, /*doSave=*/true);
const auto [transform, clip_rect] =
GetTransformAndClipRect(drawing_stroke_state().page_index);
canvas.clipRect(clip_rect);
for (const auto& segment : in_progress_stroke) {
auto status = skia_renderer.Draw(nullptr, segment, transform, canvas);
CHECK(status.ok());
}
}
PdfInkModule::TransformAndClipRect PdfInkModule::GetTransformAndClipRect(
int page_index) {
const gfx::Vector2dF origin_offset = client_->GetViewportOriginOffset();
const PageOrientation rotation = client_->GetOrientation();
const gfx::Rect content_rect = client_->GetPageContentsRect(page_index);
const gfx::SizeF page_size_in_points =
client_->GetPageSizeInPoints(page_index);
ink::AffineTransform transform = GetInkRenderTransform(
origin_offset, rotation, content_rect, page_size_in_points);
return {transform, GetDrawPageClipRect(content_rect, origin_offset)};
}
void PdfInkModule::GenerateAndSendInkThumbnail(
int page_index,
const gfx::Size& thumbnail_size) {
CHECK(!thumbnail_size.IsEmpty());
auto info = SkImageInfo::Make(thumbnail_size.width(), thumbnail_size.height(),
kRGBA_8888_SkColorType, kUnpremul_SkAlphaType);
const size_t alloc_size = info.computeMinByteSize();
CHECK(!SkImageInfo::ByteSizeOverflowed(alloc_size));
std::vector<uint8_t> image_data(alloc_size);
SkBitmap sk_bitmap;
sk_bitmap.installPixels(info, image_data.data(), info.minRowBytes());
SkCanvas canvas(sk_bitmap);
if (!DrawThumbnail(canvas, page_index)) {
return;
}
client_->PostMessage(CreateUpdateThumbnailMessage(
page_index,
/*is_ink=*/true, std::move(image_data), thumbnail_size));
}
void PdfInkModule::GenerateAndSendInkThumbnailInternal(int page_index) {
return GenerateAndSendInkThumbnail(page_index,
client_->GetThumbnailSize(page_index));
}
bool PdfInkModule::DrawThumbnail(SkCanvas& canvas, int page_index) {
auto it = strokes_.find(page_index);
if (it == strokes_.end() || it->second.empty()) {
return false;
}
const ink::AffineTransform transform = GetInkThumbnailTransform(
gfx::SkISizeToSize(canvas.imageInfo().dimensions()),
client_->GetOrientation(), client_->GetPageContentsRect(page_index),
client_->GetZoom());
ink::SkiaRenderer skia_renderer;
for (const FinishedStrokeState& finished_stroke : it->second) {
if (!finished_stroke.should_draw) {
continue;
}
auto status =
skia_renderer.Draw(nullptr, finished_stroke.stroke, transform, canvas);
CHECK(status.ok());
}
// No need to draw in-progress strokes, since DrawThumbnail() only gets called
// after the in-progress strokes finish.
return true;
}
void PdfInkModule::RequestThumbnailUpdates(
const base::flat_set<int>& ink_updates,
const base::flat_set<int>& pdf_updates) {
for (int page_index : ink_updates) {
GenerateAndSendInkThumbnailInternal(page_index);
}
for (int page_index : pdf_updates) {
client_->RequestThumbnail(
page_index, base::BindOnce(&PdfInkModule::OnGotThumbnail,
weak_factory_.GetWeakPtr(), page_index));
}
}
void PdfInkModule::OnGotThumbnail(int page_index, Thumbnail thumbnail) {
client_->PostMessage(CreateUpdateThumbnailMessage(
page_index,
/*is_ink=*/false, thumbnail.TakeData(), thumbnail.image_size()));
}
bool PdfInkModule::HandleInputEvent(const blink::WebInputEvent& event) {
if (mode_ != InkAnnotationMode::kDraw) {
return false;
}
switch (event.GetType()) {
case blink::WebInputEvent::Type::kMouseDown: {
return OnMouseDown(static_cast<const blink::WebMouseEvent&>(event));
}
case blink::WebInputEvent::Type::kMouseUp:
return OnMouseUp(static_cast<const blink::WebMouseEvent&>(event));
case blink::WebInputEvent::Type::kMouseMove:
return OnMouseMove(static_cast<const blink::WebMouseEvent&>(event));
// Touch and pen input events are blink::WebTouchEvent instances.
case blink::WebInputEvent::Type::kTouchStart:
return OnTouchStart(static_cast<const blink::WebTouchEvent&>(event));
case blink::WebInputEvent::Type::kTouchEnd:
return OnTouchEnd(static_cast<const blink::WebTouchEvent&>(event));
case blink::WebInputEvent::Type::kTouchMove:
return OnTouchMove(static_cast<const blink::WebTouchEvent&>(event));
default:
return false;
}
}
bool PdfInkModule::OnMessage(const base::Value::Dict& message) {
using MessageHandler = void (PdfInkModule::*)(const base::Value::Dict&);
static constexpr auto kMessageHandlers =
base::MakeFixedFlatMap<std::string_view, MessageHandler>({
{"annotationRedo", &PdfInkModule::HandleAnnotationRedoMessage},
{"annotationUndo", &PdfInkModule::HandleAnnotationUndoMessage},
{"finishTextAnnotation",
&PdfInkModule::HandleFinishTextAnnotationMessage},
{"getAllTextAnnotations",
&PdfInkModule::HandleGetAllTextAnnotationsMessage},
{"getAnnotationBrush",
&PdfInkModule::HandleGetAnnotationBrushMessage},
{"setAnnotationBrush",
&PdfInkModule::HandleSetAnnotationBrushMessage},
{"setAnnotationMode", &PdfInkModule::HandleSetAnnotationModeMessage},
{"startTextAnnotation",
&PdfInkModule::HandleStartTextAnnotationMessage},
});
auto it = kMessageHandlers.find(*message.FindString("type"));
if (it == kMessageHandlers.end()) {
return false;
}
MessageHandler handler = it->second;
(this->*handler)(message);
return true;
}
void PdfInkModule::OnGeometryChanged() {
// If the highlighter tool is selected, and zooming moves the cursor onto
// text, the cursor should be an I-beam, but it will instead be the drawing
// cursor until a mousemove event occurs. There is not a way to get the new
// mouse position on geometry change.
MaybeSetCursor();
}
const PdfInkBrush* PdfInkModule::GetPdfInkBrushForTesting() const {
return is_drawing_stroke() ? &GetDrawingBrush() : nullptr;
}
bool PdfInkModule::OnMouseDown(const blink::WebMouseEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
blink::WebMouseEvent normalized_event = NormalizeMouseEvent(event);
if (normalized_event.button != blink::WebPointerProperties::Button::kLeft) {
return false;
}
gfx::PointF position = normalized_event.PositionInWidget();
if (is_drawing_stroke()) {
MaybeFinishStrokeForMissingMouseUpEvent();
if (IsHighlightingTextAtPosition(position)) {
return StartTextHighlight(position, event.ClickCount(), event.TimeStamp(),
ink::StrokeInput::ToolType::kMouse);
}
return StartStroke(position, event.TimeStamp(),
ink::StrokeInput::ToolType::kMouse);
}
return StartEraseStroke(position, ink::StrokeInput::ToolType::kMouse);
}
bool PdfInkModule::OnMouseUp(const blink::WebMouseEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
if (event.button != blink::WebPointerProperties::Button::kLeft) {
return false;
}
gfx::PointF position = event.PositionInWidget();
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
return FinishTextHighlight(position, /*is_multi_click=*/false,
ink::StrokeInput::ToolType::kMouse);
}
return is_drawing_stroke()
? FinishStroke(position, event.TimeStamp(),
ink::StrokeInput::ToolType::kMouse)
: FinishEraseStroke(position, ink::StrokeInput::ToolType::kMouse);
}
bool PdfInkModule::OnMouseMove(const blink::WebMouseEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
// Before the multi-click text selection timer fired, the mouse moved to a new
// position, so the click count can no longer increment. Fire the timer
// immediately.
if (features::kPdfInk2TextHighlighting.Get() &&
text_selection_click_timer_.IsRunning()) {
text_selection_click_timer_.FireNow();
}
gfx::PointF position = event.PositionInWidget();
bool still_interacting_with_ink =
event.GetModifiers() & blink::WebInputEvent::kLeftButtonDown;
if (still_interacting_with_ink) {
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
return ContinueTextHighlight(position);
}
return is_drawing_stroke()
? ContinueStroke(position, event.TimeStamp(),
ink::StrokeInput::ToolType::kMouse)
: ContinueEraseStroke(position,
ink::StrokeInput::ToolType::kMouse);
}
// Some other view consumed the input events sometime after the stroke was
// started, and the input end event went missing for PdfInkModule. Notice
// that now, and compensate by synthesizing a mouse-up input event at the
// last known input position. Intentionally do not use `position`.
if (is_drawing_stroke()) {
MaybeSetCursorOnMouseMove(position);
DrawingStrokeState& state = drawing_stroke_state();
if (!state.input_last_event.has_value()) {
// Ignore when not drawing.
return false;
}
const DrawingStrokeState::EventDetails& input_last_event =
state.input_last_event.value();
return OnMouseUp(GenerateLeftMouseUpEvent(input_last_event.position,
input_last_event.timestamp));
}
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
// Mouse up event does not modify the text selection, so the position does
// not matter here.
return OnMouseUp(
GenerateLeftMouseUpEvent(gfx::PointF(), base::TimeTicks::Now()));
}
CHECK(is_erasing_stroke());
EraserState& state = erasing_stroke_state();
if (!state.input_last_event_position.has_value()) {
// Ignore when not erasing.
CHECK(!state.erasing);
return false;
}
// Erasing is not sensitive to particular timestamps, just use current time.
return OnMouseUp(GenerateLeftMouseUpEvent(
state.input_last_event_position.value(), base::TimeTicks::Now()));
}
bool PdfInkModule::OnTouchStart(const blink::WebTouchEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
if (event.touches_length != 1) {
return false;
}
ink::StrokeInput::ToolType tool_type = GetToolTypeFromTouchEvent(event);
MaybeRecordPenInput(tool_type);
if (ShouldIgnoreTouchInput(tool_type)) {
return false;
}
gfx::PointF position = event.touches[0].PositionInWidget();
if (is_drawing_stroke()) {
MaybeFinishStrokeForMissingMouseUpEvent();
if (IsHighlightingTextAtPosition(position)) {
// Multi-click text selection for touch is not supported.
return StartTextHighlight(position, /*click_count=*/1, event.TimeStamp(),
tool_type);
}
return StartStroke(position, event.TimeStamp(), tool_type);
}
return StartEraseStroke(position, tool_type);
}
bool PdfInkModule::OnTouchEnd(const blink::WebTouchEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
if (event.touches_length != 1) {
return false;
}
ink::StrokeInput::ToolType tool_type = GetToolTypeFromTouchEvent(event);
MaybeRecordPenInput(tool_type);
if (ShouldIgnoreTouchInput(tool_type)) {
return false;
}
gfx::PointF position = event.touches[0].PositionInWidget();
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
return FinishTextHighlight(position, /*is_multi_click=*/false, tool_type);
}
return is_drawing_stroke()
? FinishStroke(position, event.TimeStamp(), tool_type)
: FinishEraseStroke(position, tool_type);
}
bool PdfInkModule::OnTouchMove(const blink::WebTouchEvent& event) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
if (event.touches_length != 1) {
return false;
}
ink::StrokeInput::ToolType tool_type = GetToolTypeFromTouchEvent(event);
MaybeRecordPenInput(tool_type);
if (ShouldIgnoreTouchInput(tool_type)) {
return false;
}
gfx::PointF position = event.touches[0].PositionInWidget();
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
return ContinueTextHighlight(position);
}
return is_drawing_stroke()
? ContinueStroke(position, event.TimeStamp(), tool_type)
: ContinueEraseStroke(position, tool_type);
}
void PdfInkModule::MaybeFinishStrokeForMissingMouseUpEvent() {
DrawingStrokeState& state = drawing_stroke_state();
if (!state.start_time.has_value()) {
return;
}
CHECK(state.input_last_event.has_value());
const DrawingStrokeState::EventDetails& input_last_event =
state.input_last_event.value();
bool mouse_up_result = OnMouseUp(GenerateLeftMouseUpEvent(
input_last_event.position, input_last_event.timestamp));
CHECK(mouse_up_result);
}
bool PdfInkModule::StartStroke(const gfx::PointF& position,
base::TimeTicks timestamp,
ink::StrokeInput::ToolType tool_type) {
int page_index = client_->VisiblePageIndexFromPoint(position);
if (page_index < 0) {
// Do not draw when not on a page.
return false;
}
client_->StrokeStarted();
CHECK(is_drawing_stroke());
DrawingStrokeState& state = drawing_stroke_state();
gfx::PointF page_position =
GetEventToCanonicalTransformForPage(page_index).MapPoint(position);
CHECK(!state.start_time.has_value());
state.start_time = timestamp;
state.page_index = page_index;
// Start of the first segment of a stroke.
ink::StrokeInputBatch segment;
auto result =
segment.Append(CreateInkStrokeInput(tool_type, page_position,
/*elapsed_time=*/base::TimeDelta()));
CHECK(result.ok());
state.inputs.push_back(std::move(segment));
// Invalidate area around this one point.
client_->Invalidate(GetDrawingBrush().GetInvalidateArea(position, position));
std::optional<PdfInkUndoRedoModel::DiscardedDrawCommands> discards =
undo_redo_model_.StartDraw();
CHECK(discards.has_value());
ApplyUndoRedoDiscards(discards.value());
// Remember this location and timestamp to support invalidating all of the
// area between this location and the next position, and to possibly
// compensate for missed input events.
CHECK(!state.input_last_event.has_value());
state.input_last_event =
DrawingStrokeState::EventDetails{position, timestamp, tool_type};
return true;
}
bool PdfInkModule::ContinueStroke(const gfx::PointF& position,
base::TimeTicks timestamp,
ink::StrokeInput::ToolType tool_type) {
CHECK(is_drawing_stroke());
DrawingStrokeState& state = drawing_stroke_state();
if (!state.start_time.has_value()) {
// Ignore when not drawing.
return false;
}
CHECK(state.input_last_event.has_value());
const gfx::PointF last_position = state.input_last_event.value().position;
if (position == last_position) {
// Since the position did not change, do nothing.
return true;
}
if (state.input_last_event.value().tool_type != tool_type) {
// Ignore if the user is simultaneously using a different input type.
return true;
}
const int page_index = client_->VisiblePageIndexFromPoint(position);
const int last_page_index = client_->VisiblePageIndexFromPoint(last_position);
if (page_index != state.page_index && last_page_index != state.page_index) {
// If `position` is outside the page, and so was `last_position`, then just
// update `last_input_event` and treat the event as handled.
state.input_last_event =
DrawingStrokeState::EventDetails{position, timestamp, tool_type};
return true;
}
CHECK_GE(state.page_index, 0);
if (page_index != state.page_index) {
// `position` is outside the page, and `last_position` is inside the page.
CHECK_EQ(last_page_index, state.page_index);
const gfx::PointF boundary_position = CalculatePageBoundaryIntersectPoint(
client_->GetPageContentsRect(state.page_index), last_position,
position);
if (boundary_position != last_position) {
// Record the last point before leaving the page, if `last_position` was
// not already on the page boundary.
if (RecordStrokePosition(boundary_position, timestamp, tool_type)) {
client_->Invalidate(GetDrawingBrush().GetInvalidateArea(
last_position, boundary_position));
}
}
// Remember `position` and `timestamp` for use in the next event and treat
// event as handled.
state.input_last_event =
DrawingStrokeState::EventDetails{position, timestamp, tool_type};
return true;
}
gfx::PointF invalidation_position = last_position;
if (last_page_index != state.page_index) {
// If the stroke left the page and is now re-entering, then start a new
// segment.
CHECK(!state.inputs.back().IsEmpty());
state.inputs.push_back(ink::StrokeInputBatch());
const gfx::PointF boundary_position = CalculatePageBoundaryIntersectPoint(
client_->GetPageContentsRect(state.page_index), position,
last_position);
if (boundary_position != position) {
// Record the first point after entering the page.
if (RecordStrokePosition(boundary_position, timestamp, tool_type)) {
invalidation_position = boundary_position;
}
}
}
if (RecordStrokePosition(position, timestamp, tool_type)) {
// Invalidate area covering a straight line between this position and the
// previous one.
client_->Invalidate(
GetDrawingBrush().GetInvalidateArea(position, invalidation_position));
}
// Remember `position` and `timestamp` for use in the next event.
state.input_last_event =
DrawingStrokeState::EventDetails{position, timestamp, tool_type};
return true;
}
bool PdfInkModule::FinishStroke(const gfx::PointF& position,
base::TimeTicks timestamp,
ink::StrokeInput::ToolType tool_type) {
// Process `position` as though it was the last point of movement first,
// before moving on to various bookkeeping tasks.
if (!ContinueStroke(position, timestamp, tool_type)) {
return false;
}
CHECK(is_drawing_stroke());
DrawingStrokeState& state = drawing_stroke_state();
auto in_progress_stroke_segments = CreateInProgressStrokeSegmentsFromInputs();
if (!in_progress_stroke_segments.empty()) {
CHECK_GE(state.page_index, 0);
ink::Envelope invalidate_envelope;
for (const auto& segment : in_progress_stroke_segments) {
InkStrokeId id = stroke_id_generator_.GetIdAndAdvance();
ink::Stroke stroke = segment.CopyToStroke();
client_->StrokeAdded(state.page_index, id, stroke);
invalidate_envelope.Add(stroke.GetShape().Bounds());
strokes_[state.page_index].push_back(
FinishedStrokeState(std::move(stroke), id));
bool undo_redo_success = undo_redo_model_.Draw(id);
CHECK(undo_redo_success);
}
client_->Invalidate(CanonicalInkEnvelopeToInvalidationScreenRect(
invalidate_envelope, client_->GetOrientation(),
client_->GetPageContentsRect(state.page_index), client_->GetZoom()));
}
client_->StrokeFinished(/*modified=*/true);
GenerateAndSendInkThumbnailInternal(state.page_index);
bool undo_redo_success = undo_redo_model_.FinishDraw();
CHECK(undo_redo_success);
ReportDrawStroke(state.brush_type, GetDrawingBrush().ink_brush(), tool_type);
// Reset `state` now that the stroke operation is done.
state.inputs.clear();
state.start_time = std::nullopt;
state.page_index = -1;
state.input_last_event.reset();
bool set_drawing_brush = MaybeSetDrawingBrush();
if (IsHighlightingTextAtPosition(position)) {
client_->UpdateInkCursor(ui::mojom::CursorType::kIBeam);
} else if (set_drawing_brush) {
MaybeSetCursor();
}
return true;
}
bool PdfInkModule::StartEraseStroke(const gfx::PointF& position,
ink::StrokeInput::ToolType tool_type) {
int page_index = client_->VisiblePageIndexFromPoint(position);
if (page_index < 0) {
// Do not erase when not on a page.
return false;
}
client_->StrokeStarted();
CHECK(is_erasing_stroke());
EraserState& state = erasing_stroke_state();
CHECK(!state.erasing);
state.erasing = true;
std::optional<PdfInkUndoRedoModel::DiscardedDrawCommands> discards =
undo_redo_model_.StartErase();
CHECK(discards.has_value());
ApplyUndoRedoDiscards(discards.value());
EraseHelper(position, page_index);
// Remember this position to possibly compensate for missed input events.
CHECK(!state.input_last_event_position.has_value());
state.input_last_event_position = position;
state.tool_type = tool_type;
return true;
}
bool PdfInkModule::ContinueEraseStroke(const gfx::PointF& position,
ink::StrokeInput::ToolType tool_type) {
CHECK(is_erasing_stroke());
EraserState& state = erasing_stroke_state();
if (!state.erasing) {
return false;
}
state.tool_type = tool_type;
int page_index = client_->VisiblePageIndexFromPoint(position);
if (page_index < 0) {
// Do nothing when the eraser tool is in use, but the event position is
// off-page. Treat the event as handled to be consistent with
// ContinueStroke(), and so that nothing else attempts to handle this event.
// Remember this position for possible use in the next event.
state.input_last_event_position = position;
return true;
}
EraseHelper(position, page_index);
// Remember this position for possible use in the next event.
state.input_last_event_position = position;
return true;
}
bool PdfInkModule::FinishEraseStroke(const gfx::PointF& position,
ink::StrokeInput::ToolType tool_type) {
// Process `position` as though it was the last point of movement first,
// before moving on to various bookkeeping tasks.
if (!ContinueEraseStroke(position, tool_type)) {
return false;
}
bool undo_redo_success = undo_redo_model_.FinishErase();
CHECK(undo_redo_success);
CHECK(is_erasing_stroke());
EraserState& state = erasing_stroke_state();
const bool modified =
!state.page_indices_with_stroke_erasures.empty() ||
!state.page_indices_with_partitioned_mesh_erasures.empty();
if (modified) {
RequestThumbnailUpdates(
/*ink_updates=*/state.page_indices_with_stroke_erasures,
/*pdf_updates=*/state.page_indices_with_partitioned_mesh_erasures);
ReportEraseStroke(tool_type);
}
client_->StrokeFinished(modified);
// Reset `state` now that the erase operation is done.
state.erasing = false;
state.page_indices_with_stroke_erasures.clear();
state.page_indices_with_partitioned_mesh_erasures.clear();
state.input_last_event_position.reset();
state.tool_type = ink::StrokeInput::ToolType::kUnknown;
if (MaybeSetDrawingBrush()) {
MaybeSetCursor();
}
return true;
}
void PdfInkModule::EraseHelper(const gfx::PointF& position, int page_index) {
CHECK_GE(page_index, 0);
const gfx::PointF canonical_position =
GetEventToCanonicalTransformForPage(page_index).MapPoint(position);
const ink::Rect eraser_rect = GetEraserRect(canonical_position);
ink::Envelope invalidate_envelope;
bool erased_stroke = false;
if (auto stroke_it = strokes_.find(page_index); stroke_it != strokes_.end()) {
for (auto& stroke : stroke_it->second) {
if (!stroke.should_draw) {
// Already erased.
continue;
}
// No transform needed, as `eraser_rect` is already using transformed
// coordinates from `canonical_position`.
const ink::PartitionedMesh& shape = stroke.stroke.GetShape();
if (!ink::Intersects(eraser_rect, shape, kIdentityTransform)) {
continue;
}
stroke.should_draw = false;
client_->UpdateStrokeActive(page_index, stroke.id, /*active=*/false);
invalidate_envelope.Add(shape.Bounds());
erased_stroke = true;
bool undo_redo_success = undo_redo_model_.EraseStroke(stroke.id);
CHECK(undo_redo_success);
}
}
bool erased_partitioned_mesh = false;
if (auto shape_it = loaded_v2_shapes_.find(page_index);
shape_it != loaded_v2_shapes_.end()) {
for (auto& shape_state : shape_it->second) {
if (!shape_state.should_draw) {
// Already erased.
continue;
}
// No transform needed, as `eraser_rect` is already using transformed
// coordinates from `canonical_position`.
if (!ink::Intersects(eraser_rect, shape_state.shape,
kIdentityTransform)) {
continue;
}
shape_state.should_draw = false;
client_->UpdateShapeActive(page_index, shape_state.id, /*active=*/false);
invalidate_envelope.Add(shape_state.shape.Bounds());
erased_partitioned_mesh = true;
bool undo_redo_success = undo_redo_model_.EraseShape(shape_state.id);
CHECK(undo_redo_success);
}
}
if (invalidate_envelope.IsEmpty()) {
CHECK(!erased_stroke);
CHECK(!erased_partitioned_mesh);
return;
}
// If `invalidate_envelope` isn't empty, then something got erased.
client_->Invalidate(CanonicalInkEnvelopeToInvalidationScreenRect(
invalidate_envelope, client_->GetOrientation(),
client_->GetPageContentsRect(page_index), client_->GetZoom()));
CHECK(erased_stroke || erased_partitioned_mesh);
EraserState& state = erasing_stroke_state();
if (erased_stroke) {
state.page_indices_with_stroke_erasures.insert(page_index);
}
if (erased_partitioned_mesh) {
state.page_indices_with_partitioned_mesh_erasures.insert(page_index);
}
}
bool PdfInkModule::StartTextHighlight(const gfx::PointF& position,
int click_count,
base::TimeTicks timestamp,
ink::StrokeInput::ToolType tool_type) {
client_->StrokeStarted();
current_tool_state_.emplace<TextHighlightState>();
bool is_double_click = click_count == 2;
bool is_triple_click = click_count == 3;
if (is_double_click) {
StartTextSelectionMultiClickTimer(tool_type);
} else if (is_triple_click) {
StopTextSelectionMultiClickTimer();
// Clicking the same text position two times will select the word. An
// additional third click will select the line. `StartTextHighlight()` is
// called for every click count, so the two click text selection has already
// been processed in a previous call. Undo that highlight.
ApplyUndoRedoCommands(undo_redo_model_.Undo());
}
std::optional<PdfInkUndoRedoModel::DiscardedDrawCommands> discards =
undo_redo_model_.StartDraw();
CHECK(discards.has_value());
ApplyUndoRedoDiscards(discards.value());
// Notifying the client will update the text selection.
client_->OnTextOrLinkAreaClick(position, click_count);
if (is_double_click || is_triple_click) {
return FinishTextHighlight(position, /*is_multi_click=*/true, tool_type);
}
return true;
}
bool PdfInkModule::ContinueTextHighlight(const gfx::PointF& position) {
CHECK(is_text_highlighting());
auto& state = text_highlight_state();
if (state.finished_multi_click) {
// This text highlight has already processed multi-click text selection, so
// do not extend the selection.
return true;
}
client_->ExtendSelectionByPoint(position);
state.highlight_strokes = GetTextSelectionAsStrokes();
return true;
}
bool PdfInkModule::FinishTextHighlight(const gfx::PointF& position,
bool is_multi_click,
ink::StrokeInput::ToolType tool_type) {
CHECK(is_text_highlighting());
auto& state = text_highlight_state();
if (!state.finished_multi_click) {
auto& highlight_strokes = state.highlight_strokes;
highlight_strokes = GetTextSelectionAsStrokes();
for (const auto& [page_index, strokes] : highlight_strokes) {
for (const auto& stroke : strokes) {
InkStrokeId id = stroke_id_generator_.GetIdAndAdvance();
client_->StrokeAdded(page_index, id, stroke);
strokes_[page_index].push_back(
FinishedStrokeState(std::move(stroke), id));
bool undo_redo_success = undo_redo_model_.Draw(id);
CHECK(undo_redo_success);
}
GenerateAndSendInkThumbnailInternal(page_index);
}
const bool modified = !highlight_strokes.empty();
if (modified) {
if (!text_selection_click_timer_.IsRunning()) {
ReportTextHighlight(highlighter_brush_.ink_brush(), tool_type);
}
// Invalidation is already handled by the client during text selection.
}
bool undo_redo_success = undo_redo_model_.FinishDraw();
CHECK(undo_redo_success);
client_->ClearSelection();
// Only call StrokeFinished() in this block, where
// `!state.finished_multi_click` is false.
client_->StrokeFinished(modified);
}
if (is_multi_click) {
// Stay in text highlight state to handle any additional events.
state.finished_multi_click = true;
return true;
}
// Reset state back to a drawing highlighter brush.
current_tool_state_.emplace<DrawingStrokeState>();
drawing_stroke_state().brush_type = PdfInkBrush::Type::kHighlighter;
if (!client_->IsSelectableTextOrLinkArea(position)) {
MaybeSetCursor();
}
return true;
}
ink::Stroke PdfInkModule::GetHighlightStrokeFromSelectionRect(
const gfx::Rect& selection_rect) {
CHECK(is_text_highlighting());
TextSelectionHighlightStrokeData stroke_data =
GetTextSelectionHighlightStrokeData(gfx::RectF(selection_rect));
ink::StrokeInputBatch batch;
ink::StrokeInput input = CreateInkStrokeInput(
ink::StrokeInput::ToolType::kMouse, stroke_data.first_point,
/*elapsed_time=*/base::TimeDelta());
auto result = batch.Append(input);
CHECK(result.ok()) << result.message();
// Skip the second input point if it matches the first input point.
if (stroke_data.first_point != stroke_data.second_point) {
input = CreateInkStrokeInput(ink::StrokeInput::ToolType::kMouse,
stroke_data.second_point,
/*elapsed_time=*/base::TimeDelta());
result = batch.Append(input);
CHECK(result.ok()) << result.message();
}
// Make a copy of the ink brush to avoid modifying the drawing highlighter.
ink::Brush ink_brush = highlighter_brush_.ink_brush();
result = ink_brush.SetSize(stroke_data.brush_size);
CHECK(result.ok()) << result.message();
return ink::Stroke(ink_brush, batch);
}
PdfInkModule::TextSelectionHighlightStrokeData
PdfInkModule::GetTextSelectionHighlightStrokeData(
const gfx::RectF& selection_rect) {
// The stroke should be drawn along the largest dimension, so have the brush
// size equal the smallest dimension.
float brush_size = std::min(selection_rect.width(), selection_rect.height());
bool is_vertical_stroke = brush_size == selection_rect.width();
brush_size /= client_->GetZoom();
PageOrientation orientation = client_->GetOrientation();
// The first input point will always either be the top center of the text
// characters or the left center of the text characters, depending on the
// orientation and whether `selection_rect` is longer vertically. The second
// input point will be on the opposite end of the rect.
gfx::PointF start;
gfx::PointF end;
if (is_vertical_stroke) {
start = selection_rect.top_center();
end = selection_rect.bottom_center();
if (orientation == PageOrientation::kClockwise180 ||
orientation == PageOrientation::kClockwise270) {
std::swap(start, end);
}
} else {
start = selection_rect.left_center();
end = selection_rect.right_center();
if (orientation == PageOrientation::kClockwise90 ||
orientation == PageOrientation::kClockwise180) {
std::swap(start, end);
}
}
int page_index = client_->PageIndexFromPoint(selection_rect.origin());
CHECK_GE(page_index, 0);
gfx::Transform transform = GetEventToCanonicalTransformForPage(page_index);
start = transform.MapPoint(start);
end = transform.MapPoint(end);
// These points need to be offset to account for brush size. Depending on the
// direction of the stroke, the points will need to be offset in either the x
// or y axis. Strokes will always be drawn along the largest dimension of the
// rectangle.
gfx::Vector2dF offset(brush_size / 2, 0);
if (is_vertical_stroke != IsTransposedPageOrientation(orientation)) {
offset.Transpose();
}
start += offset;
end -= offset;
return TextSelectionHighlightStrokeData{
.first_point = start, .second_point = end, .brush_size = brush_size};
}
std::map<int, std::vector<ink::Stroke>>
PdfInkModule::GetTextSelectionAsStrokes() {
std::map<int, std::vector<ink::Stroke>> result;
for (const gfx::Rect& selection_rect : client_->GetSelectionRects()) {
int page_index =
client_->PageIndexFromPoint(gfx::PointF(selection_rect.origin()));
// A selection rect's origin should always be on a page.
CHECK_GE(page_index, 0);
result[page_index].push_back(
{GetHighlightStrokeFromSelectionRect(selection_rect)});
}
return result;
}
void PdfInkModule::StartTextSelectionMultiClickTimer(
ink::StrokeInput::ToolType tool_type) {
text_selection_click_timer_.Start(
FROM_HERE, base::Milliseconds(ui::kDoubleClickTimeMs),
base::BindOnce(&ReportTextHighlight, highlighter_brush_.ink_brush(),
tool_type));
}
void PdfInkModule::StopTextSelectionMultiClickTimer() {
text_selection_click_timer_.Stop();
}
void PdfInkModule::MaybeRecordPenInput(ink::StrokeInput::ToolType tool_type) {
if (tool_type == ink::StrokeInput::ToolType::kStylus) {
using_stylus_instead_of_touch_ = true;
}
}
bool PdfInkModule::ShouldIgnoreTouchInput(
ink::StrokeInput::ToolType tool_type) {
return using_stylus_instead_of_touch_ &&
tool_type == ink::StrokeInput::ToolType::kTouch;
}
void PdfInkModule::HandleAnnotationRedoMessage(
const base::Value::Dict& message) {
ApplyUndoRedoCommands(undo_redo_model_.Redo());
}
void PdfInkModule::HandleAnnotationUndoMessage(
const base::Value::Dict& message) {
ApplyUndoRedoCommands(undo_redo_model_.Undo());
}
void PdfInkModule::HandleGetAllTextAnnotationsMessage(
const base::Value::Dict& message) {
// TODO(crbug.com/408926609): Fill in this method. For now, just return an
// empty set of annotations.
client_->PostMessage(
PrepareReplyMessage(message).Set("annotations", base::Value::List()));
}
void PdfInkModule::HandleGetAnnotationBrushMessage(
const base::Value::Dict& message) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
base::Value::Dict reply = PrepareReplyMessage(message);
// Get the brush type from `message` or the current brush type if not
// provided.
const std::string* brush_type_message = message.FindString("brushType");
std::string brush_type_string;
if (brush_type_message) {
brush_type_string = *brush_type_message;
} else {
brush_type_string =
is_drawing_stroke()
? PdfInkBrush::TypeToString(drawing_stroke_state().brush_type)
: "eraser";
}
base::Value::Dict data;
data.Set("type", brush_type_string);
if (brush_type_string == "eraser") {
reply.Set("data", std::move(data));
client_->PostMessage(std::move(reply));
return;
}
std::optional<PdfInkBrush::Type> brush_type =
PdfInkBrush::StringToType(brush_type_string);
CHECK(brush_type.has_value());
const ink::Brush& ink_brush = GetBrush(brush_type.value()).ink_brush();
data.Set("size", ink_brush.GetSize());
SkColor color = GetSkColorFromInkBrush(ink_brush);
data.Set("color", base::Value::Dict()
.Set("r", static_cast<int>(SkColorGetR(color)))
.Set("g", static_cast<int>(SkColorGetG(color)))
.Set("b", static_cast<int>(SkColorGetB(color))));
reply.Set("data", std::move(data));
client_->PostMessage(std::move(reply));
}
void PdfInkModule::HandleSetAnnotationBrushMessage(
const base::Value::Dict& message) {
CHECK_EQ(InkAnnotationMode::kDraw, mode_);
const base::Value::Dict* data = message.FindDict("data");
CHECK(data);
const std::string& brush_type_string = *data->FindString("type");
if (brush_type_string == "eraser") {
// TODO(crbug.com/342445982): Handle tool changes during text highlighting.
if (is_drawing_stroke()) {
DrawingStrokeState& state = drawing_stroke_state();
if (state.start_time.has_value()) {
// PdfInkModule is currently drawing a stroke. Finish that before
// transitioning, using the last known input.
CHECK(state.input_last_event.has_value());
const DrawingStrokeState::EventDetails& input_last_event =
state.input_last_event.value();
FinishStroke(input_last_event.position, input_last_event.timestamp,
input_last_event.tool_type);
}
current_tool_state_.emplace<EraserState>();
} else {
// Do not adjust `current_tool_state_` if an erase stroke is already
// in-progress. Changes to the tool state will only apply to subsequent
// strokes.
if (!erasing_stroke_state().erasing) {
current_tool_state_.emplace<EraserState>();
}
}
MaybeSetCursor();
return;
}
float size = base::checked_cast<float>(data->FindDouble("size").value());
CHECK(PdfInkBrush::IsToolSizeInRange(size));
if (is_erasing_stroke()) {
EraserState& state = erasing_stroke_state();
if (state.erasing) {
// An erasing stroke is in-progress. Finish that off before
// transitioning, using the last known input.
CHECK(state.input_last_event_position.has_value());
FinishEraseStroke(state.input_last_event_position.value(),
state.tool_type);
}
}
// All brush types except the eraser should have a color and size.
// TODO(crbug.com/342445982): Handle tool changes during text highlighting.
const base::Value::Dict* color = data->FindDict("color");
CHECK(color);
int color_r = color->FindInt("r").value();
int color_g = color->FindInt("g").value();
int color_b = color->FindInt("b").value();
CheckColorIsWithinRange(color_r);
CheckColorIsWithinRange(color_g);
CheckColorIsWithinRange(color_b);
std::optional<PdfInkBrush::Type> brush_type =
PdfInkBrush::StringToType(brush_type_string);
CHECK(brush_type.has_value());
pending_drawing_brush_state_ = PendingDrawingBrushState{
SkColorSetRGB(color_r, color_g, color_b), size, brush_type.value()};
// Do not adjust current tool state if a drawing stroke is already
// in-progress. Changes to the tool state will only apply to subsequent
// strokes.
if (is_drawing_stroke() && drawing_stroke_state().start_time.has_value()) {
return;
}
if (MaybeSetDrawingBrush()) {
MaybeSetCursor();
}
}
void PdfInkModule::HandleSetAnnotationModeMessage(
const base::Value::Dict& message) {
const std::string* mode = message.FindString("mode");
CHECK(mode);
if (*mode == "off") {
mode_ = InkAnnotationMode::kOff;
} else if (*mode == "draw") {
mode_ = InkAnnotationMode::kDraw;
} else if (*mode == "text") {
CHECK(features::kPdfInk2TextAnnotations.Get());
mode_ = InkAnnotationMode::kText;
} else {
NOTREACHED();
}
client_->OnAnnotationModeToggled(enabled());
if (enabled() && !loaded_data_from_pdf_) {
loaded_data_from_pdf_ = true;
PdfInkModuleClient::DocumentV2InkPathShapesMap loaded_v2_shapes =
client_->LoadV2InkPathsFromPdf();
for (auto& [page_index, page_shape_map] : loaded_v2_shapes) {
PageV2InkPathShapes& page_shapes = loaded_v2_shapes_[page_index];
page_shapes.reserve(page_shape_map.size());
for (auto& [shape_id, shape] : page_shape_map) {
page_shapes.emplace_back(shape, shape_id);
}
}
}
MaybeSetCursor();
}
void PdfInkModule::HandleStartTextAnnotationMessage(
const base::Value::Dict& message) {
// TODO(crbug.com/409439509): Fill in this method. For now, just create it
// so the backend doesn't CHECK when it's sent from the frontend.
}
void PdfInkModule::HandleFinishTextAnnotationMessage(
const base::Value::Dict& message) {
// TODO(crbug.com/409439509): Fill in this method. For now, just create it
// so the backend doesn't CHECK when it's sent from the frontend.
}
bool PdfInkModule::IsHighlightingTextAtPosition(
const gfx::PointF& position) const {
return features::kPdfInk2TextHighlighting.Get() &&
drawing_stroke_state().brush_type == PdfInkBrush::Type::kHighlighter &&
client_->IsSelectableTextOrLinkArea(position);
}
PdfInkBrush& PdfInkModule::GetDrawingBrush() {
// Use the const PdfInkBrush getter and remove the const qualifier to avoid
// duplicate getter logic.
return const_cast<PdfInkBrush&>(
static_cast<PdfInkModule const&>(*this).GetDrawingBrush());
}
const PdfInkBrush& PdfInkModule::GetDrawingBrush() const {
CHECK(is_drawing_stroke());
return GetBrush(drawing_stroke_state().brush_type);
}
const PdfInkBrush& PdfInkModule::GetBrush(PdfInkBrush::Type brush_type) const {
switch (brush_type) {
case (PdfInkBrush::Type::kHighlighter):
return highlighter_brush_;
case (PdfInkBrush::Type::kPen):
return pen_brush_;
}
NOTREACHED();
}
std::vector<ink::InProgressStroke>
PdfInkModule::CreateInProgressStrokeSegmentsFromInputs() const {
if (!is_drawing_stroke()) {
return {};
}
const DrawingStrokeState& state = drawing_stroke_state();
const ink::Brush& brush = GetDrawingBrush().ink_brush();
CHECK(PdfInkBrush::IsToolSizeInRange(brush.GetSize()));
std::vector<ink::InProgressStroke> stroke_segments;
stroke_segments.reserve(state.inputs.size());
for (size_t segment_number = 0; const auto& segment : state.inputs) {
++segment_number;
if (segment.IsEmpty()) {
// Only the last segment can possibly be empty, if the stroke left the
// page but never returned back in.
CHECK_EQ(segment_number, state.inputs.size());
break;
}
ink::InProgressStroke stroke;
stroke.Start(brush);
auto enqueue_results =
stroke.EnqueueInputs(segment, /*predicted_inputs=*/{});
CHECK(enqueue_results.ok());
stroke.FinishInputs();
auto update_results = stroke.UpdateShape(ink::Duration32());
CHECK(update_results.ok());
stroke_segments.push_back(std::move(stroke));
}
return stroke_segments;
}
gfx::Transform PdfInkModule::GetEventToCanonicalTransformForPage(
int page_index) {
// If the page is visible, then its screen rect must not be empty.
// GetEventToCanonicalTransform() will check this.
auto page_contents_rect = client_->GetPageContentsRect(page_index);
return GetEventToCanonicalTransform(client_->GetOrientation(),
page_contents_rect, client_->GetZoom());
}
bool PdfInkModule::RecordStrokePosition(const gfx::PointF& position,
base::TimeTicks timestamp,
ink::StrokeInput::ToolType tool_type) {
CHECK(is_drawing_stroke());
DrawingStrokeState& state = drawing_stroke_state();
gfx::PointF canonical_position =
GetEventToCanonicalTransformForPage(state.page_index).MapPoint(position);
base::TimeDelta time_diff = timestamp - state.start_time.value();
auto result = state.inputs.back().Append(
CreateInkStrokeInput(tool_type, canonical_position, time_diff));
return result.ok();
}
void PdfInkModule::ApplyUndoRedoCommands(
const PdfInkUndoRedoModel::Commands& commands) {
switch (PdfInkUndoRedoModel::GetCommandsType(commands)) {
case PdfInkUndoRedoModel::CommandsType::kNone: {
return;
}
case PdfInkUndoRedoModel::CommandsType::kDraw: {
ApplyUndoRedoCommandsHelper(
PdfInkUndoRedoModel::GetDrawCommands(commands).value(),
/*should_draw=*/true);
return;
}
case PdfInkUndoRedoModel::CommandsType::kErase: {
ApplyUndoRedoCommandsHelper(
PdfInkUndoRedoModel::GetEraseCommands(commands).value(),
/*should_draw=*/false);
return;
}
}
NOTREACHED();
}
void PdfInkModule::ApplyUndoRedoCommandsHelper(
std::set<PdfInkUndoRedoModel::IdType> ids,
bool should_draw) {
CHECK(!ids.empty());
std::set<InkStrokeId> stroke_ids;
std::set<InkModeledShapeId> shape_ids;
for (PdfInkUndoRedoModel::IdType id : ids) {
bool inserted;
if (std::holds_alternative<InkStrokeId>(id)) {
inserted = stroke_ids.insert(std::get<InkStrokeId>(id)).second;
} else {
CHECK(std::holds_alternative<InkModeledShapeId>(id));
inserted = shape_ids.insert(std::get<InkModeledShapeId>(id)).second;
}
CHECK(inserted);
}
// Sanity check strokes/shapes exist, if this method is being asked to erase
// them.
if (!stroke_ids.empty()) {
CHECK(!strokes_.empty());
}
if (!shape_ids.empty()) {
CHECK(!loaded_v2_shapes_.empty());
}
base::flat_set<int> page_indices_with_ink_thumbnail_updates;
base::flat_set<int> page_indices_with_pdf_thumbnail_updates;
for (auto& [page_index, page_ink_strokes] : strokes_) {
std::vector<InkStrokeId> page_ids;
page_ids.reserve(page_ink_strokes.size());
for (const auto& stroke : page_ink_strokes) {
page_ids.push_back(stroke.id);
}
std::vector<InkStrokeId> ids_to_apply_command;
std::ranges::set_intersection(stroke_ids, page_ids,
std::back_inserter(ids_to_apply_command));
if (ids_to_apply_command.empty()) {
continue;
}
// `it` is always valid, because all the IDs in `ids_to_apply_command` are
// in `page_ink_strokes`.
auto it = page_ink_strokes.begin();
ink::Envelope invalidate_envelope;
for (InkStrokeId id : ids_to_apply_command) {
it = std::ranges::lower_bound(
it, page_ink_strokes.end(), id, {},
[](const FinishedStrokeState& state) { return state.id; });
auto& stroke = *it;
CHECK_NE(stroke.should_draw, should_draw);
stroke.should_draw = should_draw;
client_->UpdateStrokeActive(page_index, id, should_draw);
invalidate_envelope.Add(stroke.stroke.GetShape().Bounds());
stroke_ids.erase(id);
}
client_->Invalidate(CanonicalInkEnvelopeToInvalidationScreenRect(
invalidate_envelope, client_->GetOrientation(),
client_->GetPageContentsRect(page_index), client_->GetZoom()));
page_indices_with_ink_thumbnail_updates.insert(page_index);
if (stroke_ids.empty()) {
break; // Break out of loop if there is no stroke remaining to apply.
}
}
for (auto& [page_index, page_ink_shapes] : loaded_v2_shapes_) {
std::vector<InkModeledShapeId> page_ids;
page_ids.reserve(page_ink_shapes.size());
for (const auto& shape : page_ink_shapes) {
page_ids.push_back(shape.id);
}
std::vector<InkModeledShapeId> ids_to_apply_command;
std::ranges::set_intersection(shape_ids, page_ids,
std::back_inserter(ids_to_apply_command));
if (ids_to_apply_command.empty()) {
continue;
}
// `it` is always valid, because all the IDs in `ids_to_apply_command` are
// in `page_ink_shapes`.
auto it = page_ink_shapes.begin();
ink::Envelope invalidate_envelope;
for (InkModeledShapeId id : ids_to_apply_command) {
it = std::ranges::lower_bound(
it, page_ink_shapes.end(), id, {},
[](const LoadedV2ShapeState& state) { return state.id; });
auto& shape_state = *it;
CHECK_NE(shape_state.should_draw, should_draw);
shape_state.should_draw = should_draw;
client_->UpdateShapeActive(page_index, shape_state.id, should_draw);
invalidate_envelope.Add(shape_state.shape.Bounds());
shape_ids.erase(id);
}
client_->Invalidate(CanonicalInkEnvelopeToInvalidationScreenRect(
invalidate_envelope, client_->GetOrientation(),
client_->GetPageContentsRect(page_index), client_->GetZoom()));
page_indices_with_pdf_thumbnail_updates.insert(page_index);
if (shape_ids.empty()) {
break; // Break out of loop if there is no shape remaining to apply.
}
}
RequestThumbnailUpdates(
/*ink_updates=*/page_indices_with_ink_thumbnail_updates,
/*pdf_updates=*/page_indices_with_pdf_thumbnail_updates);
}
void PdfInkModule::ApplyUndoRedoDiscards(
const PdfInkUndoRedoModel::DiscardedDrawCommands& discards) {
if (discards.empty()) {
return;
}
// Although `discards` contain the full set of IDs to discard, only the first
// ID is needed here. This is because the `page_ink_strokes` values in
// `strokes_` are in sorted order. All elements in `page_ink_strokes` with the
// first ID or larger IDs can be discarded.
const InkStrokeId start_id = *discards.begin();
for (auto& [page_index, page_ink_strokes] : strokes_) {
// Find the first element in `page_ink_strokes` whose ID >= `start_id`.
auto start = std::ranges::lower_bound(
page_ink_strokes, start_id, {},
[](const FinishedStrokeState& state) { return state.id; });
auto end = page_ink_strokes.end();
for (auto it = start; it < end; ++it) {
client_->DiscardStroke(page_index, it->id);
}
page_ink_strokes.erase(start, end);
}
// Check the pages with strokes and remove the ones that are now empty.
// Also find the maximum stroke ID that is in use.
std::optional<InkStrokeId> max_stroke_id;
for (auto it = strokes_.begin(); it != strokes_.end();) {
const auto& page_ink_strokes = it->second;
if (page_ink_strokes.empty()) {
it = strokes_.erase(it);
} else {
max_stroke_id = std::max(max_stroke_id.value_or(InkStrokeId(0)),
page_ink_strokes.back().id);
++it;
}
}
// Now that some strokes have been discarded, Let the StrokeIdGenerator know
// there are IDs available for reuse.
if (max_stroke_id.has_value()) {
// Since some stroke(s) got discarded, the maximum stroke ID value cannot be
// the max integer value. Thus adding 1 will not overflow.
CHECK_NE(max_stroke_id.value(),
InkStrokeId(std::numeric_limits<size_t>::max()));
stroke_id_generator_.ResetIdTo(
InkStrokeId(max_stroke_id.value().value() + 1));
} else {
stroke_id_generator_.ResetIdTo(InkStrokeId(0));
}
}
bool PdfInkModule::MaybeSetDrawingBrush() {
if (!pending_drawing_brush_state_.has_value()) {
return false;
}
current_tool_state_.emplace<DrawingStrokeState>();
drawing_stroke_state().brush_type = pending_drawing_brush_state_->type;
PdfInkBrush& current_brush = GetDrawingBrush();
current_brush.SetColor(pending_drawing_brush_state_->color);
current_brush.SetSize(pending_drawing_brush_state_->size);
pending_drawing_brush_state_.reset();
return true;
}
void PdfInkModule::MaybeSetCursor() {
switch (mode_) {
case InkAnnotationMode::kOff:
// Do nothing when disabled. The code outside of PdfInkModule will select
// a normal mouse cursor and switch to that.
return;
case InkAnnotationMode::kDraw: {
if (features::kPdfInk2TextHighlighting.Get() && is_text_highlighting()) {
return;
}
SkColor color;
float brush_size;
if (is_drawing_stroke()) {
const auto& ink_brush = GetDrawingBrush().ink_brush();
color = GetSkColorFromInkBrush(ink_brush);
brush_size = ink_brush.GetSize();
} else {
CHECK(is_erasing_stroke());
color = kEraserColor;
brush_size = kEraserSize;
}
SkBitmap bitmap = GenerateToolCursor(
color,
CursorDiameterFromBrushSizeAndZoom(brush_size, client_->GetZoom()));
gfx::Point hotspot(bitmap.width() / 2, bitmap.height() / 2);
client_->UpdateInkCursor(
ui::Cursor::NewCustom(std::move(bitmap), std::move(hotspot)));
return;
}
case InkAnnotationMode::kText:
// TODO(crbug.com/402546153): Update cursor for text annotation, once
// UX determines if it should always use I-beam.
client_->UpdateInkCursor(ui::mojom::CursorType::kIBeam);
return;
}
NOTREACHED();
}
void PdfInkModule::MaybeSetCursorOnMouseMove(const gfx::PointF& position) {
if (!features::kPdfInk2TextHighlighting.Get()) {
return;
}
CHECK(is_drawing_stroke());
if (drawing_stroke_state().brush_type != PdfInkBrush::Type::kHighlighter ||
!client_->IsSelectableTextOrLinkArea(position)) {
if (client_->GetCursor().type() == ui::mojom::CursorType::kIBeam) {
MaybeSetCursor();
}
return;
}
if (client_->GetCursor().type() != ui::mojom::CursorType::kIBeam) {
client_->UpdateInkCursor(ui::mojom::CursorType::kIBeam);
}
}
PdfInkModule::DrawingStrokeState::DrawingStrokeState() = default;
PdfInkModule::DrawingStrokeState::~DrawingStrokeState() = default;
PdfInkModule::EraserState::EraserState() = default;
PdfInkModule::EraserState::~EraserState() = default;
PdfInkModule::TextHighlightState::TextHighlightState() = default;
PdfInkModule::TextHighlightState::~TextHighlightState() = default;
PdfInkModule::FinishedStrokeState::FinishedStrokeState(ink::Stroke stroke,
InkStrokeId id)
: stroke(std::move(stroke)), id(id) {}
PdfInkModule::FinishedStrokeState::FinishedStrokeState(
PdfInkModule::FinishedStrokeState&&) noexcept = default;
PdfInkModule::FinishedStrokeState& PdfInkModule::FinishedStrokeState::operator=(
PdfInkModule::FinishedStrokeState&&) noexcept = default;
PdfInkModule::FinishedStrokeState::~FinishedStrokeState() = default;
PdfInkModule::LoadedV2ShapeState::LoadedV2ShapeState(ink::PartitionedMesh shape,
InkModeledShapeId id)
: shape(std::move(shape)), id(id) {}
PdfInkModule::LoadedV2ShapeState::LoadedV2ShapeState(
PdfInkModule::LoadedV2ShapeState&&) noexcept = default;
PdfInkModule::LoadedV2ShapeState& PdfInkModule::LoadedV2ShapeState::operator=(
PdfInkModule::LoadedV2ShapeState&&) noexcept = default;
PdfInkModule::LoadedV2ShapeState::~LoadedV2ShapeState() = default;
PdfInkModule::StrokeIdGenerator::StrokeIdGenerator() = default;
PdfInkModule::StrokeIdGenerator::~StrokeIdGenerator() = default;
InkStrokeId PdfInkModule::StrokeIdGenerator::GetIdAndAdvance() {
// Die intentionally if `next_stroke_id_` is about to overflow.
CHECK_NE(next_stroke_id_.value(), std::numeric_limits<size_t>::max());
InkStrokeId stroke_id = next_stroke_id_;
++next_stroke_id_.value();
return stroke_id;
}
void PdfInkModule::StrokeIdGenerator::ResetIdTo(InkStrokeId id) {
next_stroke_id_ = id;
}
} // namespace chrome_pdf