diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | app/controllers/file.php | 7 | ||||
| -rw-r--r-- | app/controllers/files_dashboard/helpers.php | 8 | ||||
| -rw-r--r-- | app/views/file/annotate_pdf.php | 25 | ||||
| -rw-r--r-- | lib/classes/JsonApi/RouteMap.php | 3 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/Files/Authority.php | 8 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php | 44 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php | 35 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php | 1 | ||||
| -rw-r--r-- | lib/filesystem/StandardFile.php | 15 | ||||
| -rw-r--r-- | lib/models/FileRef.php | 101 | ||||
| -rw-r--r-- | package-lock.json | 267 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | resources/assets/stylesheets/scss/buttons.scss | 4 | ||||
| -rw-r--r-- | resources/studip.d.ts | 7 | ||||
| -rw-r--r-- | resources/vue/apps/PdfAnnotator.vue | 78 | ||||
| -rw-r--r-- | resources/vue/components/PdfJsViewer.vue | 107 | ||||
| -rw-r--r-- | webpack.common.js | 4 |
18 files changed, 712 insertions, 4 deletions
@@ -26,6 +26,7 @@ public/.htaccess public/.rnd public/assets/javascripts/*.js public/assets/javascripts/*.js.map +public/assets/javascripts/pdfjs public/assets/stylesheets/*.css public/assets/stylesheets/*.css.map public/pictures/banner/*.gif diff --git a/app/controllers/file.php b/app/controllers/file.php index e0cfa44..1dbbf96 100644 --- a/app/controllers/file.php +++ b/app/controllers/file.php @@ -1578,6 +1578,13 @@ class FileController extends AuthenticatedController } } + /** + * Display the data-dialog UI to annotate the given fileref in PDF.js (for grading homework). + */ + public function annotate_pdf_action($file_ref_id) { + $this->file_ref = FileRef::find($file_ref_id)->toRawArray(); + $this->userFullname = User::findCurrent()->getFullName(); + } protected function loadFiles($param = 'files', $plugin = null, $with_blob = false) { diff --git a/app/controllers/files_dashboard/helpers.php b/app/controllers/files_dashboard/helpers.php index d86fc9f..1ec673d 100644 --- a/app/controllers/files_dashboard/helpers.php +++ b/app/controllers/files_dashboard/helpers.php @@ -44,6 +44,14 @@ trait Helpers Icon::create('edit'), ['data-dialog' => ''] ); + if ('application/pdf' === $fileRef->file->mime_type) { + $actionMenu->addLink( + URLHelper::getURL('dispatch.php/file/annotate_pdf/' . $fileRef->id), + _('PDF-Werkzeuge'), + Icon::create('comment'), + ['data-dialog' => 'size=big'] + ); + } $actionMenu->addLink( URLHelper::getURL('dispatch.php/file/update/'.$fileRef->id), _('Datei aktualisieren'), diff --git a/app/views/file/annotate_pdf.php b/app/views/file/annotate_pdf.php new file mode 100644 index 0000000..a732a8f --- /dev/null +++ b/app/views/file/annotate_pdf.php @@ -0,0 +1,25 @@ +<?php +/** + * @var string $file_ref_id + * @var FileRef $file_ref + * @var string $userFullname + */ +?> +<?= Studip\VueApp::create('PdfAnnotator')->withProps([ + 'file-ref' => $file_ref, + 'user-fullname' => $userFullname, + 'class' => 'annotate-pdf-root' +]) ?> +<footer data-dialog-button> + <?= Studip\Button::createAccept( + _('Speichern'), + 'save', + [ + 'onclick' => 'STUDIP.eventBus.emit("files:save-annotated-pdf")', + ] + ); ?> + <?= Studip\LinkButton::createCancel( + _('Abbrechen'), + ['data-dialog' => 'close'] + ); ?> +</footer> diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 6b2f429..86c6d92 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -636,6 +636,8 @@ class RouteMap $group->get('/file-refs/{id}/content', Routes\Files\FileRefsContentShow::class); $group->post('/file-refs/{id}/content', Routes\Files\FileRefsContentUpdate::class); + $group->post('/file-refs/{id}/annotations', Routes\Files\FileRefsAnnotationCreate::class); + $group->get('/folders/{id}', Routes\Files\FoldersShow::class); $group->patch('/folders/{id}', Routes\Files\FoldersUpdate::class); $group->delete('/folders/{id}', Routes\Files\FoldersDelete::class); @@ -651,6 +653,7 @@ class RouteMap $group->get('/files/{id}', Routes\Files\FilesShow::class); $group->get('/files/{id}/file-refs', Routes\Files\FileRefsOfFilesShow::class); + $group->get('/files/{id}/annotations', Routes\Files\FilesAnnotationsIndex::class); $this->addRelationship($group, '/files/{id}/relationships/file-refs', Routes\Files\Rel\FileRefsOfFile::class); } diff --git a/lib/classes/JsonApi/Routes/Files/Authority.php b/lib/classes/JsonApi/Routes/Files/Authority.php index 8f25bb6..f96475d 100644 --- a/lib/classes/JsonApi/Routes/Files/Authority.php +++ b/lib/classes/JsonApi/Routes/Files/Authority.php @@ -139,4 +139,12 @@ class Authority { return self::canCreateFileRefsInFolder($user, $destinationFolder) && self::canShowFolder($user, $sourceFolder); } + + public static function canAnnotateFileRef(User $user, \FileRef $fileRef) + { + $range = $fileRef->getRangeCourseId(); + return static::canCreateFileRefsInFolder($user, $fileRef->folder->getTypedFolder()) + && $GLOBALS['perm']->have_studip_perm('tutor', $fileRef->getRangeCourseId(), $user->id); + } + } diff --git a/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php b/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php new file mode 100644 index 0000000..78c0f3e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php @@ -0,0 +1,44 @@ +<?php + +namespace JsonApi\Routes\Files; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\NonJsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +class FileRefsAnnotationCreate extends NonJsonApiController +{ + use RoutesHelperTrait; + + public function invoke(Request $request, Response $response, array $args): Response + { + $user = $this->getUser($request); + + $originalFileRef = \FileRef::find($args['id']); + if (!$originalFileRef) { + throw new RecordNotFoundException(); + } + + if ('application/pdf' !== $originalFileRef->file->mime_type) { + throw new UnprocessableEntityException(); + } + + if (!Authority::canAnnotateFileRef($user, $originalFileRef)) { + throw new AuthorizationFailedException(); + } + + $folder = $originalFileRef->folder->getTypedFolder(); + $fileRef = $this->handleUpload($request, $folder); + $fileRef->content_terms_of_use_id = $originalFileRef->content_terms_of_use_id; + $fileRef->folder_id = $originalFileRef->folder_id; + + // Store annotated file, updating its metadata. + $fileRef->setAnnotatedFileVersion($originalFileRef, $user); + $fileRef->store(); + + return $this->redirectToFileRef($response, $fileRef); + } +} diff --git a/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php b/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php new file mode 100644 index 0000000..9ecc356 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php @@ -0,0 +1,35 @@ +<?php + +namespace JsonApi\Routes\Files; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +class FilesAnnotationsIndex extends JsonApiController +{ + protected $allowedIncludePaths = ['file', 'owner', 'parent', 'range', 'terms-of-use']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!$file = \File::find($args['id'])) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowFile($user = $this->getUser($request), $file)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse( + \File::findBySQL( + "`metadata` LIKE :annotationref ORDER BY `name`", + ['annotationref' => '%"annotations:original_file_id":"' .$file->id . '"%'] + ) + ); + } +} diff --git a/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php b/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php index fea4f16..65d3b30 100644 --- a/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php +++ b/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php @@ -185,6 +185,7 @@ trait RoutesHelperTrait /** * @SuppressWarnings(PHPMD.Superglobals) + * @return \FileRef|null */ protected function handleUpload(Request $request, \FolderType $folder) { diff --git a/lib/filesystem/StandardFile.php b/lib/filesystem/StandardFile.php index b11e2f5..2aaf3d4 100644 --- a/lib/filesystem/StandardFile.php +++ b/lib/filesystem/StandardFile.php @@ -248,6 +248,13 @@ class StandardFile implements FileType, ArrayAccess, StandardFileInterface ['data-dialog' => ''], 'file-edit' ); + if ('application/pdf' === $this->fileref->file->mime_type) { + $actionMenu->addLink( + URLHelper::getURL('dispatch.php/file/annotate_pdf/' . $this->fileref->id), + _('PDF-Werkzeuge'), + Icon::create('comment'), + ['data-dialog' => 'size=big'] ); + } $actionMenu->addLink( URLHelper::getURL('dispatch.php/file/update/' . $this->fileref->id), _('Datei aktualisieren'), @@ -344,6 +351,14 @@ class StandardFile implements FileType, ArrayAccess, StandardFileInterface URLHelper::getURL("dispatch.php/file/edit/{$this->getId()}", $extra_link_params), ['data-dialog' => ''] ); + + if ('application/pdf' === $this->getMimeType()) { + $buttons[] = Studip\LinkButton::createComment( + _('PDF-Werkzeuge'), + URLHelper::getURL("dispatch.php/file/annotate_pdf/{$this->getId()}", $extra_link_params), + ['data-dialog' => 'size=big'] + ); + } } if ($this->isDownloadable($GLOBALS['user']->id)) { $buttons[] = Studip\LinkButton::createDownload( diff --git a/lib/models/FileRef.php b/lib/models/FileRef.php index 34b3841..070ca0a 100644 --- a/lib/models/FileRef.php +++ b/lib/models/FileRef.php @@ -683,4 +683,105 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange $sql_data = self::getUploadedFilesSql($user_id, $begin, $end, $course_id, $unknown_license_only); return self::countBySql($sql_data['sql'], $sql_data['params']); } + + /** + * Creates appropriate metadata for an annotated (PDF) file. + * + * @param File $originalFile the original file that was annotated + * @param User $annotatorId the annotating person + * @param User $owner + * @return FileRef + * @throws Exception + */ + public function setAnnotatedFileVersion(FileRef $originalFileRef, User $annotator) + { + $metadata = $originalFileRef->file->metadata; + + $owner = User::find($metadata['homework:visible_for_user_id'] + ?? $originalFileRef->user_id); + + // Set metadata indicating who has annotated this file. + $annotators = $metadata['annotations:annotated_by']?->getArrayCopy() ?? []; + if (!in_array($annotator->id, $annotators)) { + $annotators[] = $annotator->id; + } + + // Increment the version number by one each time. + $version = ($metadata['annotations:version'] ?? 0) + 1; + + // Generate new file name with version number and the names of the users who annotated it + $originalFilename = $metadata['annotations:original_filename'] + ?? $originalFileRef->file->name; + $annotators_names = \User::findAndMapMany(function ($user) { + return $user->getFullName(); + }, $annotators); + + $pathinfo = pathinfo($originalFilename); + + $annotatedFilename = $pathinfo['filename'] + . ' Version ' . $version . ' mit Anmerkungen von ' . join(', ', $annotators_names) + . '.' . $pathinfo['extension']; + + $this->file->metadata = [ + 'homework:visible_for_user_id' => $owner->id, + 'annotations:annotated_by' => $annotators, + 'annotations:original_filename' => $originalFilename, + 'annotations:original_file_id' => $originalFileRef->file->id, + 'annotations:version' => $version, + ]; + // Set the filename on the file and store it. + $this->file->name = $annotatedFilename; + $this->file->store(); + + $this->name = $this->file->name; + + // TODO Is this the correct behavior? Maybe everyone who annotated the file should be notified? + $this->notifyFileOwner($owner, $annotator, $originalFileRef->folder->getTypedFolder()); + return $this; + } + + /** + * Notify file owner that their file was edited/annotated by another user. + * @param User $owner + * @param User $editor + * @param FolderType $folder + * @return void + */ + public function notifyFileOwner(User $owner, User $editor, FolderType $folder) + { + $tutorName = $editor->getFullName(); + $profileLink = \URLHelper::getLink('dispatch.php/profile?username=' . $editor->username); + $folderName = $folder->name; + $folderLink = \URLHelper::getLink( + 'dispatch.php/course/files/index/' . $folder->getId(), + ['cid' => $folder->range_id] + ); + $rangeName = $folder->getRangeObject()->getFullName(); + $rangeUrl = URLHelper::getLink('dispatch.php/' . $folder->getRangeObject()->getRangeUrl(), ['cid' => $folder->range_id]); + $fileLink = \URLHelper::getLink('dispatch.php/file/details/' . $this->id, ['cid' => $folder->range_id]); + + setTempLanguage($owner->id); + $subject = _('Benachrichtigung Ă¼ber PDF-Annotationen'); + $message = '<!--HTML--><p>'; + $message .= sprintf(_('%1$s hat im Verzeichnis "%2$s" in "%3$s" eine kommentierte Version Ihrer PDF-Datei hochgeladen: %4$s'), + static::formatLink($tutorName, $profileLink), + static::formatLink($folderName, $folderLink), + static::formatLink($rangeName, $rangeUrl), + static::formatLink($this->name, $fileLink), + ); + $message .= '</p>'; + restoreLanguage(); + + messaging::sendSystemMessage($owner->id, $subject, $message); + } + + /** + * Helper function for formatting a link. + * @param string $text + * @param string $url + * @return string + */ + private static function formatLink(string $text, string $url): string { + return sprintf('<a href="%s">%s</a>', htmlReady($url), htmlReady($text)); + } } diff --git a/package-lock.json b/package-lock.json index 8ce8a07..c552be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@playwright/test": "^1.33.0", "@popperjs/core": "^2.11.2", "@rsdoctor/webpack-plugin": "^1.0.1", + "@studip/pdfjs-studip": "^5.3.54", "@types/jquery": "^3.5.16", "@types/jqueryui": "^1.12.16", "@types/lodash": "^4.14.191", @@ -3618,6 +3619,199 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "dev": true }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.70.tgz", + "integrity": "sha512-nD6NGa4JbNYSZYsTnLGrqe9Kn/lCkA4ybXt8sx5ojDqZjr2i0TWAHxx/vhgfjX+i3hCdKWufxYwi7CfXqtITSA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.70", + "@napi-rs/canvas-darwin-arm64": "0.1.70", + "@napi-rs/canvas-darwin-x64": "0.1.70", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.70", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.70", + "@napi-rs/canvas-linux-arm64-musl": "0.1.70", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.70", + "@napi-rs/canvas-linux-x64-gnu": "0.1.70", + "@napi-rs/canvas-linux-x64-musl": "0.1.70", + "@napi-rs/canvas-win32-x64-msvc": "0.1.70" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.70.tgz", + "integrity": "sha512-I/YOuQ0wbkVYxVaYtCgN42WKTYxNqFA0gTcTrHIGG1jfpDSyZWII/uHcjOo4nzd19io6Y4+/BqP8E5hJgf9OmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.70.tgz", + "integrity": "sha512-4pPGyXetHIHkw2TOJHujt3mkCP8LdDu8+CT15ld9Id39c752RcI0amDHSuMLMQfAjvusA9B5kKxazwjMGjEJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.70.tgz", + "integrity": "sha512-+2N6Os9LbkmDMHL+raknrUcLQhsXzc5CSXRbXws9C3pv/mjHRVszQ9dhFUUe9FjfPhCJznO6USVdwOtu7pOrzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.70.tgz", + "integrity": "sha512-QjscX9OaKq/990sVhSMj581xuqLgiaPVMjjYvWaCmAJRkNQ004QfoSMEm3FoTqM4DRoquP8jvuEXScVJsc1rqQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.70.tgz", + "integrity": "sha512-LNakMOwwqwiHIwMpnMAbFRczQMQ7TkkMyATqFCOtUJNlE6LPP/QiUj/mlFrNbUn/hctqShJ60gWEb52ZTALbVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.70.tgz", + "integrity": "sha512-wBTOllEYNfJCHOdZj9v8gLzZ4oY3oyPX8MSRvaxPm/s7RfEXxCyZ8OhJ5xAyicsDdbE5YBZqdmaaeP5+xKxvtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.70.tgz", + "integrity": "sha512-GVUUPC8TuuFqHip0rxHkUqArQnlzmlXmTEBuXAWdgCv85zTCFH8nOHk/YCF5yo0Z2eOm8nOi90aWs0leJ4OE5Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.70.tgz", + "integrity": "sha512-/kvUa2lZRwGNyfznSn5t1ShWJnr/m5acSlhTV3eXECafObjl0VBuA1HJw0QrilLpb4Fe0VLywkpD1NsMoVDROQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.70.tgz", + "integrity": "sha512-aqlv8MLpycoMKRmds7JWCfVwNf1fiZxaU7JwJs9/ExjTD8lX2KjsO7CTeAj5Cl4aEuzxUWbJPUUE2Qu9cZ1vfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.70.tgz", + "integrity": "sha512-Q9QU3WIpwBTVHk4cPfBjGHGU4U0llQYRXgJtFtYqqGNEOKVN4OT6PQ+ve63xwIPODMpZ0HHyj/KLGc9CWc3EtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4233,6 +4427,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@studip/pdfjs-studip": { + "version": "5.3.54", + "resolved": "https://registry.npmjs.org/@studip/pdfjs-studip/-/pdfjs-studip-5.3.54.tgz", + "integrity": "sha512-soIy9BcPjLrXWt67JUyO/PHbEFRcfp8+6xntI7OODyr8geNVU3MF2HsepIeTL+OmYU36Ay87XRLm/mE+KyBWNw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.67" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -6160,6 +6367,7 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", "dev": true, + "optional": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -6173,6 +6381,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", "dev": true, + "optional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" @@ -6799,6 +7008,43 @@ "node": ">=10.13.0" } }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", + "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/core-js": { "version": "3.31.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.0.tgz", @@ -7927,7 +8173,8 @@ "resolved": "https://registry.npmjs.org/dommatrix/-/dommatrix-1.0.3.tgz", "integrity": "sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww==", "deprecated": "dommatrix is no longer maintained. Please use @thednp/dommatrix.", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/dompurify": { "version": "2.5.8", @@ -7969,6 +8216,7 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, + "optional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -8187,6 +8435,7 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, + "optional": true, "engines": { "node": ">= 0.4" } @@ -8196,6 +8445,7 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, + "optional": true, "engines": { "node": ">= 0.4" } @@ -8211,6 +8461,7 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, + "optional": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -9288,6 +9539,7 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "dev": true, + "optional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", @@ -9334,6 +9586,7 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, + "optional": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -9511,6 +9764,7 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, + "optional": true, "engines": { "node": ">= 0.4" }, @@ -9590,6 +9844,7 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "optional": true, "engines": { "node": ">= 0.4" }, @@ -9625,6 +9880,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "optional": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -11430,6 +11686,7 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, + "optional": true, "engines": { "node": ">= 0.4" } @@ -12164,6 +12421,7 @@ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz", "integrity": "sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A==", "dev": true, + "license": "Apache-2.0", "dependencies": { "dommatrix": "^1.0.3", "web-streams-polyfill": "^3.2.1" @@ -15943,10 +16201,11 @@ } }, "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } diff --git a/package.json b/package.json index 31cc444..cce9fac 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@playwright/test": "^1.33.0", "@popperjs/core": "^2.11.2", "@rsdoctor/webpack-plugin": "^1.0.1", + "@studip/pdfjs-studip": "^5.3.54", "@types/jquery": "^3.5.16", "@types/jqueryui": "^1.12.16", "@types/lodash": "^4.14.191", diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index 2b39a21..e251512 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -95,6 +95,10 @@ button.button { } } +.ui-button.comment { + @include button-with-icon(comment, clickable, info_alt); +} + /* Grouped Buttons */ .button-group { display: inline-flex; diff --git a/resources/studip.d.ts b/resources/studip.d.ts index c8797cc..ab9b78f 100644 --- a/resources/studip.d.ts +++ b/resources/studip.d.ts @@ -2,9 +2,16 @@ import { Emitter } from 'mitt'; import { URLHelper } from './assets/javascripts/lib/url_helper.d.ts'; export {}; +type SupportedMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'OPTIONS' | 'DELETE'; + declare global { interface Window { STUDIP: { + jsonapi: { + withPromises(): { + [key in SupportedMethod]: (url: string, options?: object) => Promise<unknown>; + } + } INSTALLED_LANGUAGES: { [name: string]: InstalledLanguage }; ABSOLUTE_URI_STUDIP: string; ASSETS_URL: string; diff --git a/resources/vue/apps/PdfAnnotator.vue b/resources/vue/apps/PdfAnnotator.vue new file mode 100644 index 0000000..06d425c --- /dev/null +++ b/resources/vue/apps/PdfAnnotator.vue @@ -0,0 +1,78 @@ +<template> + <div class="pdf-annotator"> + <pdf-js-viewer ref="pdfJsViewer" + :file_id="fileRef.id" + :user-fullname="userFullname"/> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import PdfJsViewer from '../components/PdfJsViewer.vue'; +import { mapActions, mapGetters } from 'vuex'; +import { httpClient } from "../../assets/javascripts/chunks/vue"; + +export default defineComponent({ + name: 'PdfAnnotator', + components: { PdfJsViewer }, + props: { + fileRef: { + type: Object, + required: true + }, + userFullname: { + type: String, + required: true + } + }, + created() { + // This event is fired by the 'save' button in the annotate_pdf view. + window.STUDIP.eventBus.on('files:save-annotated-pdf', this.savePdf); + }, + beforeUnmount() { + window.STUDIP.eventBus.off('files:save-annotated-pdf'); + }, + computed: { + ...mapGetters({ + fileRefById: 'file-refs/byId', + folderById: 'folders/byId', + }), + }, + methods: { + ...mapActions({ + loadFolder: 'folders/loadById', + }), + /** + * Make a request to Stud.IP to save the annotated file. + */ + async savePdf() { + const blob = await (this.$refs.pdfJsViewer as any).savePdf(); + const formData = new FormData(); + const filename = this.$gettext('%{originalFilename} (korrigiert)', { + originalFilename: this.fileRef.name, + }); + formData.append('file', blob, filename); + const url = `file-refs/${this.fileRef.id}/annotations`; + const res = await httpClient.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + window.location.reload(); + return res; + }, + }, +}); +</script> + +<style> +.pdf-annotator { + height: 100%; + display: flex; + flex-direction: column; + overflow-y: clip; +} +.annotate-pdf-root { + flex-grow: 1; +} +</style> diff --git a/resources/vue/components/PdfJsViewer.vue b/resources/vue/components/PdfJsViewer.vue new file mode 100644 index 0000000..8396fda --- /dev/null +++ b/resources/vue/components/PdfJsViewer.vue @@ -0,0 +1,107 @@ +<template> + <iframe ref="iframe" :src="iframeUrl" class="pdfjs-viewer-iframe" @load="onIframeLoad"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getLocale } from '../../assets/javascripts/lib/gettext'; + +interface PdfJsIframe { + contentWindow: { + setStudipUser(formattedName: string): void; + PDFViewerApplication: { + pdfDocument: { + saveDocument(): Promise<BlobPart>; + }; + }; + PDFViewerApplicationOptions: { + set(field: string, value: unknown): void; + setAll(options: Record<string, unknown>): void; + }; + }; +} + +export default defineComponent({ + props: { + file_id: { + type: String, + required: true, + }, + userFullname: { + type: String, + required: true + } + }, + computed: { + iframeUrl(): string { + return window.STUDIP.URLHelper.getURL('assets/javascripts/pdfjs/web/viewer.html', { + file: window.STUDIP.URLHelper.getURL('sendfile.php', { file_id: this.file_id }), + }); + }, + }, + mounted() { + // This is an event dispatched by PDF.js from within the iframe which + // grants us an opportunity to set configuration values before PDF.js + // is initialized. + // See webViewerLoad() in pdfjs/web/viewer.js. + window.document.addEventListener('webviewerloaded', this.onWebViewerLoaded); + // Add an event listener for dialog closing so that we can prompt the user + // before potentially losing data. + $('.studip-dialog').on('dialogbeforeclose', this.beforeDialogClose); + }, + unmounted() { + window.document.removeEventListener('webviewerloaded', this.onWebViewerLoaded); + }, + methods: { + async savePdf(): Promise<Blob> { + const data = await ( + this.$refs.iframe as unknown as PdfJsIframe + ).contentWindow.PDFViewerApplication.pdfDocument.saveDocument(); + return new Blob([data], { type: 'application/pdf' }); + }, + beforeDialogClose(evt: Event) { + if (!window.confirm(this.$gettext('Ihre Ă„nderungen wurden noch nicht gespeichert.'))) { + evt.preventDefault(); + evt.stopPropagation(); + return false; + } + + return true; + }, + onWebViewerLoaded(evt: Event) { + const iframe = this.$refs.iframe as unknown as PdfJsIframe; + // Verify that the event is from our iframe, because there could be + // multiple PdfJsViewer components on the same page. + if ((evt as CustomEvent).detail.source === iframe.contentWindow) { + const locale = getLocale() + // Stud.IP uses '_' and PDF.js uses '-' (e.g. 'de_DE', 'de-DE'). + .replace('_', '-'); + const options = { + 'localeProperties': { + lang: locale, + }, + // Disable 'User Preferences' in PDF.js so they will not + // conflict with Stud.IP-based configuration. + 'disablePreferences': true, + 'viewerCssTheme': 1 + }; + iframe.contentWindow.PDFViewerApplicationOptions.setAll(options); + } + }, + // Get the name of the current user and set it in PDF.js's options so that + // it can be applied to the annotations they create and edit. + async onIframeLoad(event: Event): Promise<void> { + (this.$refs.iframe as unknown as PdfJsIframe).contentWindow.setStudipUser( + this.userFullname + ); + }, + }, +}); +</script> + +<style scoped> +.pdfjs-viewer-iframe { + width: 100%; + height: 100vh; +} +</style> diff --git a/webpack.common.js b/webpack.common.js index e2c24cd..c2d2036 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -113,6 +113,10 @@ module.exports = { from: './node_modules/vuex/dist/vuex.global.prod.js', to: './javascripts/vuex.global.prod.js', }, + { + from: './node_modules/@studip/pdfjs-studip', + to: './javascripts/pdfjs' + }, ], }), new VueLoaderPlugin(), |
