done graphics basic

This commit is contained in:
azeeee05
2026-05-22 15:49:10 +05:30
parent d4fe33b007
commit b9e3366e65
20 changed files with 1094 additions and 105 deletions
+2
View File
@@ -9,6 +9,8 @@ configure_file(
add_library(pdfengine STATIC
src/core/engine_info.cpp
src/core/graphics_state.cpp
src/core/display_list.cpp
src/parser/pdfium_loader.cpp
src/fonts/font_face.cpp
src/fonts/hb_shaper.cpp
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <vector>
#include <memory>
#include <string>
#include <pdfengine/graphics_state.hpp>
namespace pdfengine {
class CommandVisitor;
// Base class for all drawing commands
struct Command {
virtual ~Command() = default;
virtual void accept(CommandVisitor& visitor) const = 0;
};
// --- Specific Command Types ---
struct SaveStateCommand : public Command {
void accept(CommandVisitor& visitor) const override;
};
struct RestoreStateCommand : public Command {
void accept(CommandVisitor& visitor) const override;
};
struct SetTransformCommand : public Command {
Matrix matrix;
explicit SetTransformCommand(const Matrix& m) : matrix(m) {}
void accept(CommandVisitor& visitor) const override;
};
struct FillRectCommand : public Command {
float x, y, width, height;
FillRectCommand(float x, float y, float w, float h) : x(x), y(y), width(w), height(h) {}
void accept(CommandVisitor& visitor) const override;
};
struct DrawTextCommand : public Command {
std::string text;
float x, y;
// We would eventually have a font reference here too
DrawTextCommand(std::string text, float x, float y) : text(std::move(text)), x(x), y(y) {}
void accept(CommandVisitor& visitor) const override;
};
// --- Visitor Interface ---
// The visitor interface that the renderer (or replay engine) implements
class CommandVisitor {
public:
virtual ~CommandVisitor() = default;
virtual void visit(const SaveStateCommand& cmd) = 0;
virtual void visit(const RestoreStateCommand& cmd) = 0;
virtual void visit(const SetTransformCommand& cmd) = 0;
virtual void visit(const FillRectCommand& cmd) = 0;
virtual void visit(const DrawTextCommand& cmd) = 0;
};
// --- Display List Container ---
// A container that stores a sequence of drawing commands.
class DisplayList {
public:
DisplayList() = default;
// Add commands directly
void addCommand(std::unique_ptr<Command> cmd);
// Replay the commands to a visitor (renderer)
void replay(CommandVisitor& visitor) const;
// Helper methods to easily append common commands
void saveState();
void restoreState();
void setTransform(const Matrix& m);
void fillRect(float x, float y, float w, float h);
void drawText(const std::string& text, float x, float y);
[[nodiscard]] size_t size() const noexcept { return m_commands.size(); }
void clear() { m_commands.clear(); }
private:
std::vector<std::unique_ptr<Command>> m_commands;
};
} // namespace pdfengine
@@ -0,0 +1,67 @@
#pragma once
#include <vector>
#include <stdexcept>
namespace pdfengine {
// 2D Affine Transformation Matrix (3x3 matrix optimized for 2D)
// [ a b 0 ]
// [ c d 0 ]
// [ e f 1 ]
struct Matrix {
float a = 1.0f, b = 0.0f;
float c = 0.0f, d = 1.0f;
float e = 0.0f, f = 0.0f;
Matrix() = default;
Matrix(float a, float b, float c, float d, float e, float f)
: a(a), b(b), c(c), d(d), e(e), f(f) {}
// Multiply this matrix by another matrix
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
// Transform a 2D point using this matrix
void transform(float& x, float& y) const noexcept;
};
struct Color {
float r = 0.0f;
float g = 0.0f;
float b = 0.0f;
};
// Represents the current graphics state in a PDF document
struct GraphicsState {
Matrix ctm; // Current Transformation Matrix
Color fillColor;
Color strokeColor;
float lineWidth = 1.0f;
// In the future, this will also hold:
// - Clipping paths
// - Font state (current font, font size)
// - Dash patterns
// - Line cap/join styles
};
// Manages the q/Q stack of graphics states
class GraphicsStateStack {
public:
GraphicsStateStack();
// Corresponds to the 'q' operator (save graphics state)
void push();
// Corresponds to the 'Q' operator (restore graphics state)
void pop();
// Access the current active graphics state
[[nodiscard]] GraphicsState& current();
[[nodiscard]] const GraphicsState& current() const;
private:
std::vector<GraphicsState> m_stack;
};
} // namespace pdfengine
+43
View File
@@ -0,0 +1,43 @@
#include <pdfengine/display_list.hpp>
namespace pdfengine {
void SaveStateCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); }
void RestoreStateCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); }
void SetTransformCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); }
void FillRectCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); }
void DrawTextCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); }
void DisplayList::addCommand(std::unique_ptr<Command> cmd) {
if (cmd) {
m_commands.push_back(std::move(cmd));
}
}
void DisplayList::replay(CommandVisitor& visitor) const {
for (const auto& cmd : m_commands) {
cmd->accept(visitor);
}
}
void DisplayList::saveState() {
addCommand(std::make_unique<SaveStateCommand>());
}
void DisplayList::restoreState() {
addCommand(std::make_unique<RestoreStateCommand>());
}
void DisplayList::setTransform(const Matrix& m) {
addCommand(std::make_unique<SetTransformCommand>(m));
}
void DisplayList::fillRect(float x, float y, float w, float h) {
addCommand(std::make_unique<FillRectCommand>(x, y, w, h));
}
void DisplayList::drawText(const std::string& text, float x, float y) {
addCommand(std::make_unique<DrawTextCommand>(text, x, y));
}
} // namespace pdfengine
+57
View File
@@ -0,0 +1,57 @@
#include <pdfengine/graphics_state.hpp>
namespace pdfengine {
Matrix Matrix::multiply(const Matrix& other) const noexcept {
return Matrix(
a * other.a + b * other.c,
a * other.b + b * other.d,
c * other.a + d * other.c,
c * other.b + d * other.d,
e * other.a + f * other.c + other.e,
e * other.b + f * other.d + other.f
);
}
void Matrix::transform(float& x, float& y) const noexcept {
float newX = a * x + c * y + e;
float newY = b * x + d * y + f;
x = newX;
y = newY;
}
GraphicsStateStack::GraphicsStateStack() {
// A PDF always starts with one default graphics state on the stack.
m_stack.emplace_back();
}
void GraphicsStateStack::push() {
// Duplicate the current state and push it onto the stack.
if (!m_stack.empty()) {
m_stack.push_back(m_stack.back());
} else {
m_stack.emplace_back();
}
}
void GraphicsStateStack::pop() {
// The stack should never be empty, but we must protect against popping the initial state
// if the PDF is malformed (e.g. more 'Q' operators than 'q' operators).
if (m_stack.size() > 1) {
m_stack.pop_back();
} else {
// We could throw an exception or just ignore the invalid pop.
// For a resilient engine, ignoring is often better, but we could log a warning.
// For now, we do nothing to prevent crashing on the root state.
}
}
GraphicsState& GraphicsStateStack::current() {
return m_stack.back();
}
const GraphicsState& GraphicsStateStack::current() const {
return m_stack.back();
}
} // namespace pdfengine
+2
View File
@@ -3,6 +3,8 @@
add_executable(pdfengine_smoke
smoke_test.cpp
fonts_test.cpp
graphics_state_test.cpp
display_list_test.cpp
)
target_link_libraries(pdfengine_smoke
+54
View File
@@ -0,0 +1,54 @@
#include <gtest/gtest.h>
#include <pdfengine/display_list.hpp>
#include <string>
#include <vector>
using namespace pdfengine;
// A simple visitor for testing that just records the sequence of visited commands.
class MockVisitor : public CommandVisitor {
public:
std::vector<std::string> calls;
void visit(const SaveStateCommand&) override { calls.push_back("SaveState"); }
void visit(const RestoreStateCommand&) override { calls.push_back("RestoreState"); }
void visit(const SetTransformCommand& cmd) override { calls.push_back("SetTransform(" + std::to_string(cmd.matrix.a) + ")"); }
void visit(const FillRectCommand& cmd) override { calls.push_back("FillRect(" + std::to_string(cmd.width) + ")"); }
void visit(const DrawTextCommand& cmd) override { calls.push_back("DrawText(" + cmd.text + ")"); }
};
TEST(DisplayListTest, RecordAndReplay) {
DisplayList list;
EXPECT_EQ(list.size(), 0);
// Record some commands
list.saveState();
list.setTransform(Matrix(2.0f, 0.0f, 0.0f, 2.0f, 0.0f, 0.0f));
list.fillRect(10.0f, 10.0f, 100.0f, 50.0f);
list.drawText("Hello World", 20.0f, 30.0f);
list.restoreState();
EXPECT_EQ(list.size(), 5);
// Replay to the mock visitor
MockVisitor visitor;
list.replay(visitor);
ASSERT_EQ(visitor.calls.size(), 5);
EXPECT_EQ(visitor.calls[0], "SaveState");
EXPECT_EQ(visitor.calls[1], "SetTransform(2.000000)");
EXPECT_EQ(visitor.calls[2], "FillRect(100.000000)");
EXPECT_EQ(visitor.calls[3], "DrawText(Hello World)");
EXPECT_EQ(visitor.calls[4], "RestoreState");
}
TEST(DisplayListTest, Clear) {
DisplayList list;
list.saveState();
list.fillRect(0, 0, 10, 10);
EXPECT_EQ(list.size(), 2);
list.clear();
EXPECT_EQ(list.size(), 0);
}
+64
View File
@@ -0,0 +1,64 @@
#include <gtest/gtest.h>
#include <pdfengine/graphics_state.hpp>
using namespace pdfengine;
TEST(MatrixTest, DefaultIdentity) {
Matrix m;
EXPECT_FLOAT_EQ(m.a, 1.0f);
EXPECT_FLOAT_EQ(m.b, 0.0f);
EXPECT_FLOAT_EQ(m.c, 0.0f);
EXPECT_FLOAT_EQ(m.d, 1.0f);
EXPECT_FLOAT_EQ(m.e, 0.0f);
EXPECT_FLOAT_EQ(m.f, 0.0f);
}
TEST(MatrixTest, Multiply) {
Matrix m1(2.0f, 0.0f, 0.0f, 2.0f, 10.0f, 20.0f);
Matrix m2(0.5f, 0.0f, 0.0f, 0.5f, -5.0f, -10.0f);
Matrix result = m1.multiply(m2);
EXPECT_FLOAT_EQ(result.a, 1.0f);
EXPECT_FLOAT_EQ(result.b, 0.0f);
EXPECT_FLOAT_EQ(result.c, 0.0f);
EXPECT_FLOAT_EQ(result.d, 1.0f);
EXPECT_FLOAT_EQ(result.e, 0.0f);
EXPECT_FLOAT_EQ(result.f, 0.0f);
}
TEST(MatrixTest, TransformPoint) {
Matrix m(2.0f, 0.0f, 0.0f, 3.0f, 10.0f, 20.0f);
float x = 5.0f;
float y = 5.0f;
m.transform(x, y);
EXPECT_FLOAT_EQ(x, 20.0f); // 2*5 + 10
EXPECT_FLOAT_EQ(y, 35.0f); // 3*5 + 20
}
TEST(GraphicsStateStackTest, PushPop) {
GraphicsStateStack stack;
// Initial state
stack.current().lineWidth = 5.0f;
// Push new state
stack.push();
EXPECT_FLOAT_EQ(stack.current().lineWidth, 5.0f);
// Modify current state
stack.current().lineWidth = 10.0f;
EXPECT_FLOAT_EQ(stack.current().lineWidth, 10.0f);
// Pop back to initial
stack.pop();
EXPECT_FLOAT_EQ(stack.current().lineWidth, 5.0f);
}
TEST(GraphicsStateStackTest, PopEmptyProtection) {
GraphicsStateStack stack;
// Attempting to pop the root state should be safe (ignored)
stack.pop();
stack.current().lineWidth = 2.0f; // Should still be valid
EXPECT_FLOAT_EQ(stack.current().lineWidth, 2.0f);
}
+339 -64
View File
@@ -8,8 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -57,7 +59,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -267,11 +268,31 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -476,7 +497,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -487,7 +507,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -498,7 +517,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -508,14 +526,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -526,7 +542,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -545,7 +560,6 @@
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -558,7 +572,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -575,7 +588,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -592,7 +604,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -609,7 +620,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -626,7 +636,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -643,7 +652,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -660,7 +668,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -677,7 +684,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -694,7 +700,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -711,7 +716,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -728,7 +732,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -745,7 +748,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -762,7 +764,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -781,7 +782,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -798,7 +798,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -812,14 +811,269 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -851,9 +1105,8 @@
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -864,7 +1117,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -924,7 +1176,6 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
@@ -1155,7 +1406,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1246,7 +1496,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1340,7 +1589,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1353,6 +1601,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1382,7 +1643,6 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -1584,7 +1844,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -1653,7 +1912,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1700,6 +1958,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -1767,6 +2031,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1849,7 +2122,6 @@
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1882,7 +2154,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1903,7 +2174,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1924,7 +2194,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1945,7 +2214,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1966,7 +2234,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1987,7 +2254,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2008,7 +2274,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2029,7 +2294,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2050,7 +2314,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2071,7 +2334,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2092,7 +2354,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2132,6 +2393,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2159,7 +2429,6 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2262,16 +2531,13 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2283,7 +2549,6 @@
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2333,7 +2598,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2354,7 +2618,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.130.0",
@@ -2427,17 +2690,34 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2467,7 +2747,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
@@ -2490,7 +2769,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2527,7 +2805,7 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -2575,9 +2853,7 @@
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -2702,7 +2978,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+3 -1
View File
@@ -10,8 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+42 -1
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Toolbar } from './components/Toolbar';
import { Sidebar } from './components/Sidebar';
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo } from './lib/gatewayService';
@@ -9,6 +10,7 @@ import { wasmLoader } from './lib/wasmLoader';
import './App.css';
function App() {
const viewerRef = useRef<PDFViewerRef>(null);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string>('sample-doc-1');
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
@@ -25,6 +27,35 @@ function App() {
const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline'>('documents');
const [isLoading, setIsLoading] = useState<boolean>(true);
// Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResultCount, setSearchResultCount] = useState(0);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const handleSearch = (query: string) => {
setSearchQuery(query);
// Mock search results for Phase 0
if (query) {
setSearchResultCount(5);
setSearchCurrentMatch(0);
} else {
setSearchResultCount(0);
setSearchCurrentMatch(0);
}
};
const handleSearchNext = () => {
if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev + 1) % searchResultCount);
}
};
const handleSearchPrev = () => {
if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev - 1 + searchResultCount) % searchResultCount);
}
};
// Check Gateway Health on mount
useEffect(() => {
const checkHealth = async () => {
@@ -131,6 +162,11 @@ function App() {
totalPages={activeDoc?.totalPages || 1}
onUploadStart={handleUploadStart}
backendHealthy={backendHealthy}
onSearch={handleSearch}
searchResultCount={searchResultCount}
searchCurrentMatch={searchCurrentMatch}
onSearchNext={handleSearchNext}
onSearchPrev={handleSearchPrev}
/>
<div className="flex-1 w-full flex overflow-hidden">
@@ -143,6 +179,9 @@ function App() {
totalPages={activeDoc?.totalPages || 0}
activeTab={sidebarTab}
setActiveTab={setSidebarTab}
onNavigateToPage={(pageIndex) => {
viewerRef.current?.scrollToPage(pageIndex);
}}
/>
{/* Main PDF Scroll Viewer Area */}
@@ -156,12 +195,14 @@ function App() {
</div>
) : activeDoc ? (
<PDFViewer
ref={viewerRef}
documentId={activeDoc.id}
totalPages={activeDoc.totalPages}
zoom={zoom}
rotation={rotation}
activeTool={activeTool}
annotations={annotations}
searchQuery={searchQuery}
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
/>
+58
View File
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
interface SearchBarProps {
onSearch: (query: string) => void;
resultCount: number;
currentMatch: number;
onNext: () => void;
onPrev: () => void;
}
export const SearchBar: React.FC<SearchBarProps> = ({
onSearch,
resultCount,
currentMatch,
onNext,
onPrev,
}) => {
const [query, setQuery] = useState('');
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSearch(query);
}
};
return (
<div className="flex items-center bg-slate-900/40 border border-slate-700/70 rounded-xl px-3 py-1.5 gap-2 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" className="text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="bg-transparent border-none outline-none text-slate-100 text-sm w-32 placeholder-slate-500"
/>
{query && (
<div className="flex items-center gap-1 text-xs text-slate-400 font-semibold border-l border-slate-700 pl-2">
<span>{resultCount > 0 ? `${currentMatch + 1}/${resultCount}` : '0/0'}</span>
<div className="flex flex-col gap-0.5 ml-1">
<button onClick={onPrev} className="hover:text-white" title="Previous match">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button onClick={onNext} className="hover:text-white" title="Next match">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
};
+9 -8
View File
@@ -1,6 +1,7 @@
import React from 'react';
import type { DocumentInfo } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
interface SidebarProps {
documents: DocumentInfo[];
@@ -10,6 +11,7 @@ interface SidebarProps {
totalPages: number;
activeTab: 'documents' | 'annotations' | 'outline';
setActiveTab: (tab: 'documents' | 'annotations' | 'outline') => void;
onNavigateToPage?: (pageIndex: number) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
@@ -20,6 +22,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
totalPages,
activeTab,
setActiveTab,
onNavigateToPage,
}) => {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
@@ -104,14 +107,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
{activeTab === 'outline' && (
<div className="thumbnails-grid">
{Array.from({ length: totalPages }).map((_, idx) => (
<div key={idx} className="thumbnail-card">
<div className="thumbnail-preview">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="thumbnail-svg-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="thumbnail-label">Page {idx + 1}</span>
</div>
</div>
<Thumbnail
key={idx}
documentId={selectedDocumentId}
pageIndex={idx}
onClick={() => onNavigateToPage?.(idx)}
/>
))}
</div>
)}
+75
View File
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
interface ThumbnailProps {
documentId: string;
pageIndex: number;
onClick: () => void;
}
export const Thumbnail: React.FC<ThumbnailProps> = ({
documentId,
pageIndex,
onClick,
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
const fetchThumbnail = async () => {
try {
setLoading(true);
// Request a lower resolution/zoom image for the thumbnail
const url = await gatewayService.renderPage({
documentId,
pageIndex,
zoom: 0.2, // Small zoom for thumbnail size
rotation: 0,
});
if (active) {
setImageUrl(url);
}
} catch (err) {
console.error(`Failed to load thumbnail for page ${pageIndex}`, err);
} finally {
if (active) setLoading(false);
}
};
fetchThumbnail();
return () => {
active = false;
};
}, [documentId, pageIndex]);
return (
<div className="thumbnail-card" onClick={onClick}>
<div className="thumbnail-preview overflow-hidden bg-white hover:border-indigo-500 transition-colors">
{loading ? (
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-900">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mb-2" />
<span className="thumbnail-label">Loading...</span>
</div>
) : imageUrl ? (
<img
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center h-full w-full">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="thumbnail-svg-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="thumbnail-label text-rose-400">Error</span>
</div>
)}
</div>
<span className="thumbnail-label text-center mt-1">Page {pageIndex + 1}</span>
</div>
);
};
+28 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { SearchBar } from './SearchBar';
interface ToolbarProps {
zoom: number;
@@ -13,6 +14,11 @@ interface ToolbarProps {
backendHealthy: boolean | null;
wasmEngineInfo?: string;
wasmHasSkia?: boolean;
onSearch: (query: string) => void;
searchResultCount: number;
searchCurrentMatch: number;
onSearchNext: () => void;
onSearchPrev: () => void;
}
export const Toolbar: React.FC<ToolbarProps> = ({
@@ -26,8 +32,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
totalPages,
onUploadStart,
backendHealthy,
wasmEngineInfo,
wasmHasSkia,
onSearch,
searchResultCount,
searchCurrentMatch,
onSearchNext,
onSearchPrev,
}) => {
const handleZoomPercentSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -125,6 +134,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{/* Right Tools (Active tool highlights, Upload) */}
<div className="toolbar-section gap-3">
<div className="tool-selector">
<button
onClick={() => onActiveToolChange('pan')}
className={`tool-btn ${activeTool === 'pan' ? 'active' : ''}`}
title="Pan (Drag)"
>
Pan
</button>
<button
onClick={() => onActiveToolChange('select')}
className={`tool-btn ${activeTool === 'select' ? 'active' : ''}`}
@@ -150,6 +167,15 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</button>
</div>
{/* SEARCH BAR */}
<SearchBar
onSearch={onSearch}
resultCount={searchResultCount}
currentMatch={searchCurrentMatch}
onNext={onSearchNext}
onPrev={onSearchPrev}
/>
<label className="upload-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
+2
View File
@@ -1,4 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
:root {
--sans: 'Outfit', system-ui, -apple-system, sans-serif;
+36 -24
View File
@@ -42,13 +42,17 @@ class GatewayService {
}
async listDocuments(): Promise<DocumentInfo[]> {
const response = await fetch(`${this.baseUrl}/documents`);
if (response.status === 501) {
// Return mocked document list for Phase 0 scaffolding
try {
const response = await fetch(`${this.baseUrl}/documents`);
if (response.status === 501) {
return this.getMockDocuments();
}
if (!response.ok) throw new Error(`Failed to list documents: ${response.statusText}`);
return response.json();
} catch (err) {
// Fallback if backend is not running at all
return this.getMockDocuments();
}
if (!response.ok) throw new Error(`Failed to list documents: ${response.statusText}`);
return response.json();
}
async uploadDocument(file: File): Promise<DocumentInfo> {
@@ -81,14 +85,20 @@ class GatewayService {
}
async getDocument(id: string): Promise<DocumentInfo> {
const response = await fetch(`${this.baseUrl}/documents/${id}`);
if (response.status === 501) {
try {
const response = await fetch(`${this.baseUrl}/documents/${id}`);
if (response.status === 501) {
const mock = this.getMockDocuments().find(d => d.id === id);
if (!mock) throw new Error('Document not found');
return mock;
}
if (!response.ok) throw new Error(`Failed to fetch document metadata: ${response.statusText}`);
return response.json();
} catch (err) {
const mock = this.getMockDocuments().find(d => d.id === id);
if (!mock) throw new Error('Document not found');
return mock;
}
if (!response.ok) throw new Error(`Failed to fetch document metadata: ${response.statusText}`);
return response.json();
}
async deleteDocument(id: string): Promise<{ success: boolean }> {
@@ -103,24 +113,26 @@ class GatewayService {
}
async renderPage(params: RenderParams): Promise<string> {
// Returns object URL or base64 data for the rendered page
const query = new URLSearchParams({
page: params.pageIndex.toString(),
zoom: params.zoom.toString(),
rotation: params.rotation.toString(),
}).toString();
try {
const query = new URLSearchParams({
page: params.pageIndex.toString(),
zoom: params.zoom.toString(),
rotation: params.rotation.toString(),
}).toString();
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
const response = await fetch(url);
if (response.status === 501) {
// Fallback: Generate a high-fidelity mock SVG/Canvas data URL for the page
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
const response = await fetch(url);
if (response.status === 501) {
return this.generateMockPage(params.pageIndex);
}
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (err) {
return this.generateMockPage(params.pageIndex);
}
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
}
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
+62 -4
View File
@@ -4,6 +4,7 @@ import { SelectionLayer } from './SelectionLayer';
import { AnnotationLayer } from './AnnotationLayer';
import type { Annotation } from './AnnotationLayer';
import { OverlayLayer } from './OverlayLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
@@ -14,6 +15,7 @@ interface PDFViewerProps {
rotation: number;
activeTool: string;
annotations: Annotation[];
searchQuery?: string;
onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
}
@@ -27,16 +29,21 @@ interface PageLayout {
const generateUniqueId = () => `anno_${Math.random().toString(36).substring(2, 11)}`;
export const PDFViewer: React.FC<PDFViewerProps> = ({
export interface PDFViewerRef {
scrollToPage: (pageIndex: number) => void;
}
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
documentId,
totalPages,
zoom,
rotation,
activeTool,
annotations,
searchQuery,
onAnnotationAdded,
onPageVisible,
}) => {
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
const [renderedPages, setRenderedPages] = useState<string[]>([]);
@@ -85,6 +92,14 @@ export const PDFViewer: React.FC<PDFViewerProps> = ({
});
};
React.useImperativeHandle(ref, () => ({
scrollToPage: (pageIndex: number) => {
if (containerRef.current && pageLayouts[pageIndex]) {
containerRef.current.scrollTop = pageLayouts[pageIndex].top;
}
}
}));
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
@@ -190,11 +205,45 @@ export const PDFViewer: React.FC<PDFViewerProps> = ({
}
};
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
const handleMouseDown = (e: React.MouseEvent) => {
if (activeTool === 'pan' && containerRef.current) {
setIsPanning(true);
setPanStart({
x: e.clientX,
y: e.clientY,
scrollLeft: containerRef.current.scrollLeft,
scrollTop: containerRef.current.scrollTop,
});
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isPanning && containerRef.current) {
const dx = e.clientX - panStart.x;
const dy = e.clientY - panStart.y;
containerRef.current.scrollLeft = panStart.scrollLeft - dx;
containerRef.current.scrollTop = panStart.scrollTop - dy;
}
};
const handleMouseUp = () => {
if (isPanning) {
setIsPanning(false);
}
};
return (
<div
ref={containerRef}
onScroll={handleScroll}
className="viewer-viewport"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`viewer-viewport ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
>
<div
className="viewer-content-container"
@@ -250,6 +299,15 @@ export const PDFViewer: React.FC<PDFViewerProps> = ({
height={page.height}
activeTool={activeTool}
/>
{/* Search highlights overlay */}
<SearchOverlayLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
searchQuery={searchQuery || ''}
/>
</>
) : (
<div className="page-loading-state">
@@ -263,4 +321,4 @@ export const PDFViewer: React.FC<PDFViewerProps> = ({
</div>
</div>
);
};
});
@@ -0,0 +1,61 @@
import React from 'react';
interface SearchOverlayLayerProps {
pageIndex: number;
width: number;
height: number;
zoom: number;
searchQuery: string;
}
export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
pageIndex,
width,
height,
zoom,
searchQuery,
}) => {
if (!searchQuery) return null;
// Mock some search results based on the query for Phase 0/1
// We'll just generate deterministic-looking boxes so it looks like it found something
const mockResults = [];
const hash = searchQuery.length + pageIndex;
if (hash % 3 !== 0) {
mockResults.push({
x: 100 * zoom,
y: (150 + hash * 10) * zoom,
width: 120 * zoom,
height: 18 * zoom,
});
}
if (hash % 2 === 0) {
mockResults.push({
x: 300 * zoom,
y: (250 + hash * 5) * zoom,
width: 80 * zoom,
height: 18 * zoom,
});
}
return (
<div
className="absolute top-0 left-0 pointer-events-none z-20"
style={{ width: `${width}px`, height: `${height}px` }}
>
{mockResults.map((rect, idx) => (
<div
key={idx}
className="absolute bg-yellow-400/40 border border-yellow-500/60 rounded-sm"
style={{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
))}
</div>
);
};
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
})