diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm new file mode 100644 index 000000000..9a5dea625 --- /dev/null +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -0,0 +1,142 @@ +package LANraragi::Controller::Api::Stamp; +use Mojo::Base 'Mojolicious::Controller'; + +use Redis; +use Encode; + +use LANraragi::Model::Stamp; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); + + +sub get_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + + my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp($id, $stamp_id); + + unless ($stamp) { + render_api_response($self, "get_stamp", "The given stamp does not exist."); + return; + } + + $self->render( openapi => { result => $stamp } ); +} + +sub get_stamps_by_page { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + + my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page($id, $index); + + $self->render( openapi => { result => $stamps } ); +} + +sub get_stamped_pages { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + + my ( $indexes, $err ) = LANraragi::Model::Stamp::get_stamped_pages( $id ); + + $self->render( openapi => { result => $indexes } ); +} + +sub add_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + my $content = $self->req->param('content') || ""; + my $position = $self->req->param('position') || ""; + + unless ( defined $index ) { + return render_api_response( $self, "add_stamp", "Archive page." ); + } + + return unless exec_with_lock( + $self, + "stamp-write:$id", + "create_stamp", + $id, + sub { + my ( $created_id, $err ) = LANraragi::Model::Stamp::add_stamp( $id, $index, $content, $position ); + + if ($created_id) { + $self->render( + openapi => { + operation => "add_stamp", + stamp_id => $created_id, + success => 1 + } + ); + } else { + $self->render( + openapi => { + operation => "add_stamp", + stamp_id => $created_id, + success => 0 + } + ); + } + } + ); +} + +sub update_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + my $position = $self->req->param('position') || undef; + my $content = $self->req->param('content') || undef; + + return unless exec_with_lock( + $self, + "stamp-write:$stamp_id", + "update_stamp", + $stamp_id, + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::update_stamp( $id, $stamp_id, $content, $position ); + + if ($result) { + my %stamp = LANraragi::Model::Stamp::get_stamp( $id, $stamp_id ); + my $successMessage = "Updated stamp \"$stamp_id\"!"; + + render_api_response( $self, "update_stamp", undef, $successMessage ); + } else { + render_api_response( $self, "update_stamp", $err ); + } + } + ); + +} + +sub delete_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + + return unless exec_with_lock( + $self, + "stamp-write:$stamp_id", + "delete_stamp", + $stamp_id, + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($id, $stamp_id); + + if ($result) { + render_api_response( $self, "delete_stamp" ); + } else { + render_api_response( $self, "delete_stamp", "The given stamp does not exist." ); + } + } + ); +} + +1; + diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm new file mode 100644 index 000000000..fd376acfd --- /dev/null +++ b/lib/LANraragi/Model/Stamp.pm @@ -0,0 +1,254 @@ +package LANraragi::Model::Stamp; + +use v5.36; +use experimental 'try'; + +use strict; +use warnings; +use utf8; + +use Redis; + +use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Redis qw(redis_encode); + + +# get_stamp(id, stamp_id) +# Gets the requested stamp. +# Returns the stamp object. +sub get_stamp { + my ( $id, $stamp_id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + if ( $redis->hexists($faves_id => $stamp_id) ) { + my $content = $redis->hget($faves_id => $stamp_id); + my %stamp = convert_stamp_to_object($stamp_id, $content); + + return ( \%stamp, $err ); + } + + return (); +} + +# get_stamps_by_page(id) +# Gets the list of pages that have at least one stamp. +# Returns an array of stamps objects. +# TODO Pagination +sub get_stamps_by_page { + my ( $id, $index ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + my $data = get_stamps_data($redis, $faves_id, $index); + my @stamps = convert_stamps_to_object(%$data); + + return ( \@stamps, $err ); +} + +# get_stamped_pages(id) +# Gets the list of pages that have at least one stamp. +# Returns an array of page numbers. +sub get_stamped_pages { + my ( $id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + my $fields = $redis->hkeys($faves_id); + + my %indexes; + + foreach my $field (@$fields) { + # Extract the page number + my ($index) = split(/:/, $field, 2); + $indexes{$index} = 1; + } + + my @keys = keys %indexes; + + return ( \@keys, $err ); +} + +# add_stamp(id, key, content, position) +# Add the stamp to the page. +# Returns the stamp key. +sub add_stamp { + my ( $id, $index, $content, $position ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + unless ( $redis->exists($id) ) { + $err = "$id does not exist in the database."; + $logger->error($err); + $redis->quit; + return ( 0, $err ); + } + + $content = remove_separator($content, "|"); + $position = remove_separator($position, "|"); + + # page:timestamp + # Not sure if this is the right way to make a timestamp in milliseconds, is the only thing that came to my mind + my $key = $index . ":" . int(time() * 1000); + + $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); + + $redis->quit; + + return ( $key, $err ); +} + +# update_stamp(id, key, content, position) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub update_stamp { + my ( $id, $key, $content, $position ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + my $faves_id = "FAVES_" . $id; + + my $current = $redis->hget($faves_id => $key); + my @c_content = split(/\|/, $current); + + if ( defined $position ) { + $position = remove_separator($position, "|"); + } else { + $position = $c_content[0] + } + + if ( defined $content ) { + $content = remove_separator($content, "|"); + } else { + $content = $c_content[1] + } + + if ( $redis->exists($faves_id) ) { + $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); + $redis->quit; + return ( 1, $err ); + } + + $err = "$faves_id doesn't exist in the database!"; + $logger->warn($err); + $redis->quit; + return ( 0, $err ); +} + +# remove_stamp(id, key) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub remove_stamp { + my ( $id, $key ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + my $faves_id = "FAVES_" . $id; + + if ( $redis->exists($faves_id) ) { + $redis->hdel($faves_id, $key); + $redis->quit; + return ( 1, $err ); + } + + $err = "$faves_id doesn't exist in the database!"; + $logger->warn($err); + $redis->quit; + return ( 0, $err ); +} + +# Replaces | for " " in the given string +sub remove_separator { + my ($string, $char) = @_; + + # Escape special regex characters in $char + my $escaped_char = quotemeta($char); + + # Replace all occurrences with a space + $string =~ s/$escaped_char/ /g; + + return $string; +} + +# Extracts the stamps related to a page using HSCAN +sub get_stamps_data { + my ($redis, $faves_id, $index) = @_; + + my $cursor = 0; + my %result; + my $pattern = "$index:*"; + my $logger = get_logger( "Stamps", "lanraragi" ); + + # Use a Do While until the cursor goes back to 0 + do { + my ($next_cursor, $data) = $redis->hscan($faves_id, $cursor, 'MATCH', $pattern); + + # Append data to the dictionary + for (my $i = 0; $i < @$data; $i += 2) { + my $field = $data->[$i]; + my $value = $data->[$i + 1]; + + $result{$field} = $value; + } + + $cursor = $next_cursor; + + } while ($cursor != 0); + + return \%result; +} + +# Gets the number of stamps in the page +sub size_stamps_by_page { + my ($redis, $faves_id, $index) = @_; + + my $data = get_stamps_data($redis, $faves_id, $index); + + return scalar keys %$data; +} + +# Converts a stamp register to object +sub convert_stamp_to_object { + my ( $stamp_id, $content ) = @_; + + # Separate the string and classify the fields + my @x = split(/\|/, $content); + my %stamp = ( + id => $stamp_id, + position => $x[0], + content => $x[1], + ); + + return %stamp; +} + +# Converts an array of stamp registers to an array ob objects +sub convert_stamps_to_object { + my (%stamps_raw) = @_; + + my @stamps; + + # Convert stamp registers to objects + foreach my $i (keys %stamps_raw) { + my %stamp = convert_stamp_to_object($i, $stamps_raw{$i}); + push @stamps, \%stamp; + } + + return @stamps; +} + +1; \ No newline at end of file diff --git a/public/css/lrr.css b/public/css/lrr.css index 8cfcdfa39..d92e7296c 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -304,6 +304,34 @@ p#nb { user-select: none; align-self: center; cursor: pointer; + z-index: 22; +} + +.marker { + position: absolute; + width: 24px; + height: 24px; + + background-image: url('../favicon.ico'); /* your icon path */ + background-size: contain; + background-repeat: no-repeat; + background-position: center; + + transform: translate(-50%, -50%); + pointer-events: auto; + z-index: 23; + cursor: pointer; +} + +.focus-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 21; + display: none; } .caption-reader { diff --git a/public/js/reader.js b/public/js/reader.js index a3d58807f..4ae5d5621 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -26,6 +26,11 @@ Reader.scrollConfig = { Reader.autoNextPage = false; Reader.autoNextPageCountdownTaskId = undefined; Reader.autoNextPageCountdown = 0; +Reader.markerMode = false; +Reader.markersVisible = false; +Reader.markers = []; +Reader.overlayFiltered = false; +Reader.pageNaviState = true; Reader.initializeAll = function () { Reader.initializeSettings(); @@ -61,6 +66,7 @@ Reader.initializeAll = function () { $(document).on("click.toggle-archive-overlay", "#toggle-archive-overlay", Reader.toggleArchiveOverlay); $(document).on("click.toggle-settings-overlay", "#toggle-settings-overlay", Reader.toggleSettingsOverlay); $(document).on("click.toggle-help", "#toggle-help", Reader.toggleHelp); + $(document).on("click.toggle-stamps", "#toggle-stamps", Reader.toggleStamps); $(document).on("click.toggle-bookmark", ".toggle-bookmark", Reader.toggleBookmark); $(document).on("click.regenerate-archive-cache", "#regenerate-cache", () => { window.location.href = new LRR.apiURL(`/reader?id=${Reader.id}&force_reload`); @@ -133,6 +139,80 @@ Reader.initializeAll = function () { Reader.goToPage(pageNumber); }); + $(document).on("click.reader-image", ".reader-image", (e) => { + if (!Reader.markerMode) return; + + // Compute marker position + // This basically estimates the percentage of the width and legth of the image + // where the user clicked, so later from this percentage can be reversed + // without being affected by if the image got scaled up or down + const img = e.currentTarget; + + const rect = img.getBoundingClientRect(); + + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const xPercent = (clickX / rect.width) * 100; + const yPercent = (clickY / rect.height) * 100; + + const markerData = { + x: xPercent, + y: yPercent, + name: `Marker`, + left: true, + }; + + let page = Reader.currentPage; + + if (Reader.doublePageMode && Reader.currentPage > 0 + && Reader.currentPage < Reader.maxPage) { + if (img.id == "img_doublepage") { + page += 1; + markerData.left = false; + } + } + LRR.showPopUp({ + title: I18N.StampName, + input: "text", + inputPlaceholder: I18N.StampPlaceholder, + inputAttributes: { + autocapitalize: "off", + }, + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + $("#overlay-page").hide(); + Reader.markerMode = false; + Reader.toggleArchiveOverlay(); + if (result.isConfirmed && result.value.trim() !== "") { + Server.callAPI(`/api/stamps/${Reader.id}/${page}?position=${markerData.x},${markerData.y}&content=${result.value}`, "PUT", "Stamp added!", I18N.StampError, + (data) => { + markerData.id = data["stamp_id"]; + markerData.name = result.value; + + Reader.markers.push(markerData); + Reader.renderMarkers(); + } + ); + } + }); + e.stopPropagation(); + }); + + // Press esc to cancel set stamp action + $(document).on("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Escape" && Reader.markerMode) { + $("#overlay-page").hide(); + Reader.markerMode = false; + Reader.toggleArchiveOverlay(); + Reader.pageNaviState = true; + } + }); + $(document).on("click.set-stamp", "#set-stamp", Reader.addStamp); + $(document).on("click.filter-stamped", "#filter-stamped", Reader.filterStampedOverlay); + // Apply full-screen utility // F11 Fullscreen is totally another "Fullscreen", so its support is beyong consideration. @@ -375,7 +455,7 @@ Reader.loadImages = function () { // when click left or right img area change page $(document).on("click", (event) => { // check click Y position is in img Y area - if ($(event.target).closest("#i3").length && !$("#overlay-shade").is(":visible")) { + if ($(event.target).closest("#i3").length && !$("#overlay-shade").is(":visible") && Reader.pageNaviState) { // is click X position is left on screen or right if (event.pageX < $(window).width() / 2) { Reader.changePage(-1, true); @@ -711,6 +791,222 @@ Reader.toggleHelp = function () { // all toggable panes need to return false to avoid scrolling to top }; +Reader.addStamp = function () { + Reader.markerMode = true; + LRR.closeOverlay(); + $("#overlay-page").show(); +}; + +Reader.createMarkerElement = function (markerData, index) { + if (markerData.left) { + const img = document.getElementById("img"); + } else { + const img = document.getElementById("img_doublepage"); + } + + const display = document.getElementById("display"); + const container = document.getElementById("i1"); + + const marker = document.createElement("div"); + marker.className = "marker"; + + // Compute the px coordinates from the percentage based coordinates + const rect = img.getBoundingClientRect(); + const xPx = (markerData.x / 100) * rect.width; + const yPx = (markerData.y / 100) * rect.height; + + const displayRect = display.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let leftFix = rect.left - containerRect.left; + let topFix = rect.top - containerRect.top; + + if (!markerData.left) { + // Add the width of the left page plus the left and right margin + const img = document.getElementById("img"); + leftFix += img.width+2; + } + + marker.style.left = `${rect.left + xPx - displayRect.left + leftFix}px`; + marker.style.top = `${rect.top + yPx - displayRect.top + topFix}px`; + + marker.title = markerData.name; + marker.dataset.index = index; + + // Edit + let isDragging = false; + + marker.addEventListener("mousedown", (e) => { + e.stopPropagation(); + isDragging = true; + + // So no text gets selected during the D&D + document.body.style.userSelect = "none"; + Reader.pageNaviState = false; + }); + + document.addEventListener("mousemove", (e) => { + if (!isDragging) return; + + const imgRect = img.getBoundingClientRect(); + const dispRect = display.getBoundingClientRect(); + + // Ensure that the stamp remains inside the image + let x = e.clientX - imgRect.left + leftFix; + let y = e.clientY - imgRect.top + topFix; + + x = Math.max(leftFix, Math.min(x, imgRect.width + leftFix)); + y = Math.max(topFix, Math.min(y, imgRect.height + topFix)); + + marker.style.left = `${imgRect.left + x - dispRect.left}px`; + marker.style.top = `${imgRect.top + y - dispRect.top}px`; + }); + + document.addEventListener("mouseup", (e) => { + e.stopPropagation(); + // Each marker individually run this event when on mouseup + // this line ensures that only one of them execute the action + // also a good improvement would be to change this to an attachable event only for the dragged marker + if (!isDragging) return; + + isDragging = false; + document.body.style.userSelect = "auto"; + + const imgRect = img.getBoundingClientRect(); + + let x = e.clientX - imgRect.left; + let y = e.clientY - imgRect.top; + + x = Math.max(0, Math.min(x, imgRect.width)); + y = Math.max(0, Math.min(y, imgRect.height)); + + const xPercent = (x / imgRect.width) * 100; + const yPercent = (y / imgRect.height) * 100; + + const i = marker.dataset.index; + let inputValue = markerData.name; + + LRR.showPopUp({ + title: I18N.StampName, + input: "text", + inputPlaceholder: I18N.StampPlaceholder, + inputAttributes: { + autocapitalize: "off", + }, + inputValue, + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + if (result.isConfirmed && result.value.trim() !== "") { + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}&content=${result.value}&position=${xPercent},${yPercent}`, "PUT", "Stamp updated!", I18N.StampError, + () => { + Reader.markers[i].x = xPercent; + Reader.markers[i].y = yPercent; + Reader.markers[i].name = result.value; + + Reader.pageNaviState = true; + Reader.renderMarkers(); + } + ); + } else { + Reader.pageNaviState = true; + Reader.renderMarkers(); + } + }); + }); + + + // Delete + marker.addEventListener("contextmenu", (e) => { + e.preventDefault(); + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}`, "DELETE", "Stamp deleted!", I18N.StampError, + () => { + const i = marker.dataset.index; + + Reader.markers.splice(i, 1); + Reader.renderMarkers(); + } + ); + }); + + display.appendChild(marker); +} + +Reader.renderMarkers = function () { + // Clean markers + const existing = document.querySelectorAll(".marker"); + existing.forEach(el => el.remove()); + + if (!Reader.markersVisible) return; + + // Draw markers + Reader.markers.forEach((markerData, index) => { + Reader.createMarkerElement(markerData, index); + }); +} + +Reader.toggleStamps = function () { + // Show or hide the markers + Reader.markersVisible = !Reader.markersVisible; + if (Reader.markersVisible) { + $("#toggle-stamps").removeClass('fa-eye-slash').addClass('fa-eye'); + } else { + $("#toggle-stamps").removeClass('fa-eye').addClass('fa-eye-slash'); + } + Reader.renderMarkers(); +} + +Reader.loadStamps = function (currentPage) { + Reader.markers = []; + // Call for the first page + Server.callAPI(`/api/stamps/${Reader.id}/${currentPage}`, "GET", null, I18N.ServerInfoError, + (data) => { + let markerData = {}; + + for (var i = data.result.length - 1; i >= 0; i--) { + markerData = {}; + let x = data.result[i].position.split(",")[0]; + let y = data.result[i].position.split(",")[1]; + markerData.x = x; + markerData.y = y; + markerData.name = data.result[i].content + markerData.id = data.result[i].id + markerData.left = true; + Reader.markers.push(markerData); + } + + if (Reader.doublePageMode && Reader.currentPage > 0 + && Reader.currentPage < Reader.maxPage) { + + // Call for the second page + Server.callAPI(`/api/stamps/${Reader.id}/${currentPage+1}`, "GET", null, I18N.ServerInfoError, + (data) => { + let markerData = {}; + + for (var i = data.result.length - 1; i >= 0; i--) { + markerData = {}; + let x = data.result[i].position.split(",")[0]; + let y = data.result[i].position.split(",")[1]; + markerData.x = x; + markerData.y = y; + markerData.name = data.result[i].content + markerData.id = data.result[i].id + markerData.left = false; + Reader.markers.push(markerData); + } + + // Render markers + Reader.renderMarkers(); + } + ); + } else { + // Render markers + Reader.renderMarkers(); + } + } + ); +} + Reader.toggleBookmark = function (e) { e.preventDefault(); if (!localStorage.getItem("bookmarkCategoryId")) { @@ -897,6 +1193,10 @@ Reader.goToPage = async function (page) { }; Reader.updateProgress = function () { + // Clear markers + Reader.markers = []; + Reader.renderMarkers(); + // Send an API request to update progress on the server if (Reader.authenticateProgress && LRR.isUserLogged()) { Server.updateServerSideProgress(Reader.id, Reader.currentPage + 1); @@ -905,6 +1205,11 @@ Reader.updateProgress = function () { } else if (!Reader.authenticateProgress) { Server.updateServerSideProgress(Reader.id, Reader.currentPage + 1); } + + // Load stamps + if (!Reader.infiniteScroll) { + const stamps = Reader.loadStamps(Reader.currentPage); + } }; Reader.preloadImages = function () { @@ -1238,6 +1543,47 @@ Reader.updateArchiveOverlay = function (forceUpdate = false) { $("#archivePagesOverlay").attr("loaded", "true"); }; +Reader.filterStampedOverlay = function () { + if (Reader.overlayFiltered) { + Reader.overlayFiltered = false; + Reader.updateArchiveOverlay(true); + } else { + Server.callAPI(`/api/stamps/pages/${Reader.id}`, "GET", null, I18N.ServerInfoError, + (data) => { + $("#extract-spinner").hide(); + let pages = data.result.sort(); + + // For each link in the pages array, craft a div and jam it in the overlay. + let htmlBlob = ""; + for (let page = 0; page < pages.length; page++) { + const index = parseInt(pages[page]); + + const thumbCss = (localStorage.cropthumbs === "true") ? "id3" : "id3 nocrop"; + const thumbnailUrl = new LRR.apiURL(`/api/archives/${Reader.id}/thumbnail?page=${index+1}`); + + let thumbnail = ` +