viewer: image editor
parent
2147c422e2
commit
3d0905628a
File diff suppressed because it is too large
Load Diff
|
@ -34,6 +34,7 @@
|
|||
"@nextcloud/sharing": "^0.1.0",
|
||||
"@nextcloud/vue": "7.0.0",
|
||||
"camelcase": "^7.0.0",
|
||||
"filerobot-image-editor": "^4.3.7",
|
||||
"justified-layout": "^4.1.0",
|
||||
"moment": "^2.29.4",
|
||||
"path-posix": "^1.0.0",
|
||||
|
|
|
@ -0,0 +1,582 @@
|
|||
<template>
|
||||
<div ref="editor" class="viewer__image-editor" v-bind="themeDataAttr" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
|
||||
import { basename, dirname, extname, join } from "path";
|
||||
import { emit } from "@nextcloud/event-bus";
|
||||
import { showError, showSuccess } from "@nextcloud/dialogs";
|
||||
import axios from "@nextcloud/axios";
|
||||
import FilerobotImageEditor from "filerobot-image-editor";
|
||||
|
||||
import translations from "./ImageEditorTranslations";
|
||||
|
||||
const { TABS, TOOLS } = FilerobotImageEditor as any;
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||
@Prop() fileid: number;
|
||||
@Prop() mime: string;
|
||||
@Prop() src: string;
|
||||
|
||||
private imageEditor: FilerobotImageEditor = null;
|
||||
|
||||
get config() {
|
||||
return {
|
||||
source: this.src,
|
||||
|
||||
defaultSavedImageName: this.defaultSavedImageName,
|
||||
defaultSavedImageType: this.defaultSavedImageType,
|
||||
// We use our own translations
|
||||
useBackendTranslations: false,
|
||||
|
||||
// Watch resize
|
||||
observePluginContainerSize: true,
|
||||
|
||||
// Default tab and tool
|
||||
defaultTabId: TABS.ADJUST,
|
||||
defaultToolId: TOOLS.CROP,
|
||||
|
||||
// Displayed tabs, disabling watermark
|
||||
tabsIds: Object.values(TABS)
|
||||
.filter((tab) => tab !== TABS.WATERMARK)
|
||||
.sort((a: string, b: string) => a.localeCompare(b)),
|
||||
|
||||
// onBeforeSave: this.onBeforeSave,
|
||||
onClose: this.onClose,
|
||||
// onModify: this.onModify,
|
||||
onSave: this.onSave,
|
||||
|
||||
// Translations
|
||||
translations,
|
||||
|
||||
theme: {
|
||||
palette: {
|
||||
"bg-secondary": "var(--color-main-background)",
|
||||
"bg-primary": "var(--color-background-dark)",
|
||||
// Accent
|
||||
"accent-primary": "var(--color-primary)",
|
||||
// Use by the slider
|
||||
"border-active-bottom": "var(--color-primary)",
|
||||
"icons-primary": "var(--color-main-text)",
|
||||
// Active state
|
||||
"bg-primary-active": "var(--color-background-dark)",
|
||||
"bg-primary-hover": "var(--color-background-hover)",
|
||||
"accent-primary-active": "var(--color-main-text)",
|
||||
// Used by the save button
|
||||
"accent-primary-hover": "var(--color-primary)",
|
||||
|
||||
warning: "var(--color-error)",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "var(--font-face)",
|
||||
},
|
||||
},
|
||||
|
||||
savingPixelRatio: 1,
|
||||
previewPixelRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
get defaultSavedImageName() {
|
||||
return basename(this.src, extname(this.src));
|
||||
}
|
||||
|
||||
get defaultSavedImageType() {
|
||||
return extname(this.src).slice(1) || "jpeg";
|
||||
}
|
||||
|
||||
get hasHighContrastEnabled() {
|
||||
const themes = globalThis.OCA?.Theming?.enabledThemes || [];
|
||||
return themes.find((theme) => theme.indexOf("highcontrast") !== -1);
|
||||
}
|
||||
|
||||
get themeDataAttr() {
|
||||
if (this.hasHighContrastEnabled) {
|
||||
return {
|
||||
"data-theme-dark-highcontrast": true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
"data-theme-dark": true,
|
||||
};
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.imageEditor = new FilerobotImageEditor(
|
||||
<any>this.$refs.editor,
|
||||
<any>this.config
|
||||
);
|
||||
this.imageEditor.render();
|
||||
window.addEventListener("keydown", this.handleKeydown, true);
|
||||
window.addEventListener("DOMNodeInserted", this.handleSfxModal);
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.imageEditor) {
|
||||
this.imageEditor.terminate();
|
||||
}
|
||||
window.removeEventListener("keydown", this.handleKeydown, true);
|
||||
}
|
||||
|
||||
onClose(closingReason, haveNotSavedChanges) {
|
||||
if (haveNotSavedChanges) {
|
||||
this.onExitWithoutSaving();
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("keydown", this.handleKeydown, true);
|
||||
this.$emit("close");
|
||||
}
|
||||
|
||||
/**
|
||||
* User saved the image
|
||||
*
|
||||
* @see https://github.com/scaleflex/filerobot-image-editor#onsave
|
||||
* @param {object} props destructuring object
|
||||
* @param {string} props.fullName the file name
|
||||
* @param {HTMLCanvasElement} props.imageCanvas the image canvas
|
||||
* @param {string} props.mimeType the image mime type
|
||||
* @param {number} props.quality the image saving quality
|
||||
*/
|
||||
async onSave({
|
||||
fullName,
|
||||
imageCanvas,
|
||||
mimeType,
|
||||
quality,
|
||||
}: {
|
||||
fullName: string;
|
||||
imageCanvas: HTMLCanvasElement;
|
||||
mimeType: string;
|
||||
quality: number;
|
||||
}): Promise<void> {
|
||||
const { origin, pathname } = new URL(this.src);
|
||||
const putUrl = origin + join(dirname(pathname), fullName);
|
||||
|
||||
// toBlob is not very smart...
|
||||
mimeType = mimeType.replace("jpg", "jpeg");
|
||||
|
||||
// Sanity check, 0 < quality < 1
|
||||
quality = Math.max(Math.min(quality, 1), 0) || 1;
|
||||
|
||||
try {
|
||||
const blob = await new Promise((resolve: BlobCallback) =>
|
||||
imageCanvas.toBlob(resolve, mimeType, quality)
|
||||
);
|
||||
const response = await axios.put(putUrl, new File([blob], fullName));
|
||||
|
||||
showSuccess(this.t("viewer", "Image saved successfully"));
|
||||
if (putUrl !== this.src) {
|
||||
emit("files:file:created", {
|
||||
fileid:
|
||||
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null,
|
||||
});
|
||||
} else {
|
||||
emit("files:file:updated", { fileid: this.fileid });
|
||||
}
|
||||
} catch (error) {
|
||||
showError(this.t("viewer", "Error saving image"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning if unsaved changes
|
||||
*/
|
||||
onExitWithoutSaving() {
|
||||
(<any>OC.dialogs).confirmDestructive(
|
||||
translations.changesLoseConfirmation +
|
||||
"\n\n" +
|
||||
translations.changesLoseConfirmationHint,
|
||||
this.t("viewer", "Unsaved changes"),
|
||||
{
|
||||
type: (<any>OC.dialogs).YES_NO_BUTTONS,
|
||||
confirm: this.t("viewer", "Drop changes"),
|
||||
confirmClasses: "error",
|
||||
cancel: translations.cancel,
|
||||
},
|
||||
(decision) => {
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
this.onClose("warning-ignored", false);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Key Handlers, override default Viewer arrow and escape key
|
||||
handleKeydown(event) {
|
||||
event.stopImmediatePropagation();
|
||||
// escape key
|
||||
if (event.key === "Escape") {
|
||||
// Since we cannot call the closeMethod and know if there
|
||||
// are unsaved changes, let's fake a close button trigger.
|
||||
event.preventDefault();
|
||||
(
|
||||
document.querySelector(".FIE_topbar-close-button") as HTMLElement
|
||||
).click();
|
||||
}
|
||||
|
||||
// ctrl + S = save
|
||||
if (event.ctrlKey && event.key === "s") {
|
||||
event.preventDefault();
|
||||
(
|
||||
document.querySelector(".FIE_topbar-save-button") as HTMLElement
|
||||
).click();
|
||||
}
|
||||
|
||||
// ctrl + Z = undo
|
||||
if (event.ctrlKey && event.key === "z") {
|
||||
event.preventDefault();
|
||||
(
|
||||
document.querySelector(".FIE_topbar-undo-button") as HTMLElement
|
||||
).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch out for Modal inject in document root
|
||||
* That way we can adjust the focusTrap
|
||||
*
|
||||
* @param {Event} event Dom insertion event
|
||||
*/
|
||||
handleSfxModal(event) {
|
||||
if (
|
||||
event.target?.classList &&
|
||||
event.target.classList.contains("SfxModal-Wrapper")
|
||||
) {
|
||||
emit("viewer:trapElements:changed", event.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Take full screen size ()
|
||||
.viewer__image-editor {
|
||||
position: absolute;
|
||||
z-index: 10100;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Make sure the editor and its modals are above everything
|
||||
.SfxModal-Wrapper {
|
||||
z-index: 10101 !important;
|
||||
}
|
||||
|
||||
.SfxPopper-wrapper {
|
||||
z-index: 10102 !important;
|
||||
}
|
||||
|
||||
// Default styling
|
||||
.viewer__image-editor,
|
||||
.SfxModal-Wrapper,
|
||||
.SfxPopper-wrapper {
|
||||
* {
|
||||
// Fix font size for the entire image editor
|
||||
font-size: var(--default-font-size) !important;
|
||||
}
|
||||
|
||||
label,
|
||||
button {
|
||||
color: var(--color-main-text);
|
||||
> span {
|
||||
font-size: var(--default-font-size) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix button ratio and center content
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Input styling
|
||||
.SfxInput-root {
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
.SfxInput-Base {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Select styling
|
||||
.SfxSelect-root {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
// Global buttons
|
||||
.SfxButton-root {
|
||||
min-height: 44px !important;
|
||||
margin: 0 !important;
|
||||
border: transparent !important;
|
||||
&[color="error"] {
|
||||
color: white !important;
|
||||
background-color: var(--color-error) !important;
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: white !important;
|
||||
background-color: var(--color-error-hover) !important;
|
||||
}
|
||||
}
|
||||
&[color="primary"] {
|
||||
color: var(--color-primary-text) !important;
|
||||
background-color: var(--color-primary-element) !important;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-primary-element-hover) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menu items
|
||||
.SfxMenuItem-root {
|
||||
height: 44px;
|
||||
padding-left: 8px !important;
|
||||
// Center the menu entry icon and fix width
|
||||
> div {
|
||||
margin-right: 0;
|
||||
padding: 14px;
|
||||
// Minus the parent padding-left
|
||||
padding: calc(14px - 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Disable jpeg saving (jpg is already here)
|
||||
&[value="jpeg"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal
|
||||
.SfxModal-Container {
|
||||
min-height: 300px;
|
||||
padding: 22px;
|
||||
|
||||
// Fill height
|
||||
.SfxModal-root,
|
||||
.SfxModalTitle-root {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.SfxModalTitle-Icon {
|
||||
margin-bottom: 22px !important;
|
||||
background: none !important;
|
||||
// Fit EmptyContent styling
|
||||
svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.4;
|
||||
// Override all coloured icons
|
||||
|
||||
--color-primary: var(--color-main-text);
|
||||
--color-error: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
// Hide close icon (use cancel button)
|
||||
.SfxModalTitle-Close {
|
||||
display: none !important;
|
||||
}
|
||||
// Modal actions buttons display
|
||||
.SfxModalActions-root {
|
||||
justify-content: space-evenly !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Header buttons
|
||||
.FIE_topbar-center-options > button,
|
||||
.FIE_topbar-center-options > label {
|
||||
margin-left: 6px !important;
|
||||
}
|
||||
|
||||
// Tabs
|
||||
.FIE_tabs {
|
||||
padding: 6px !important;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.FIE_tab {
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-large) !important;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
&-label {
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-hover) !important;
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--color-main-text);
|
||||
background-color: var(--color-background-dark);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-element);
|
||||
}
|
||||
}
|
||||
|
||||
// Tools bar
|
||||
.FIE_tools-bar {
|
||||
&-wrapper {
|
||||
max-height: max-content !important;
|
||||
}
|
||||
|
||||
// Matching buttons tools
|
||||
& > div[class$="-tool-button"],
|
||||
& > div[class$="-tool"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
}
|
||||
|
||||
// Crop preset select button
|
||||
.FIE_crop-presets-opener-button {
|
||||
// override default button width
|
||||
min-width: 0 !important;
|
||||
padding: 5px !important;
|
||||
padding-left: 10px !important;
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
// Force icon-only style
|
||||
.FIE_topbar-history-buttons button,
|
||||
.FIE_topbar-close-button,
|
||||
.FIE_resize-ratio-locker {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-hover) !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Left top bar buttons
|
||||
.FIE_topbar-history-buttons button {
|
||||
&.FIE_topbar-reset-button {
|
||||
&::before {
|
||||
content: attr(title);
|
||||
font-weight: normal;
|
||||
}
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save button fixes
|
||||
.FIE_topbar-save-button {
|
||||
color: var(--color-primary-text) !important;
|
||||
border: none !important;
|
||||
background-color: var(--color-primary-element) !important;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-primary-element-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Save Modal fixes
|
||||
.FIE_resize-tool-options {
|
||||
.FIE_resize-width-option,
|
||||
.FIE_resize-height-option {
|
||||
flex: 1 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize lock
|
||||
.FIE_resize-ratio-locker {
|
||||
margin-right: 8px !important;
|
||||
// Icon is very thin
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
path {
|
||||
stroke-width: 1;
|
||||
stroke: var(--color-main-text);
|
||||
fill: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close editor button fixes
|
||||
.FIE_topbar-close-button {
|
||||
svg path {
|
||||
// The path viewbox is weird and
|
||||
// not correct, this fixes it
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas container
|
||||
.FIE_canvas-container {
|
||||
background-color: var(--color-main-background) !important;
|
||||
}
|
||||
|
||||
// Loader
|
||||
.FIE_spinner::after,
|
||||
.FIE_spinner-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.FIE_spinner-wrapper {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.FIE_spinner::before {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -16px 0 0 -16px;
|
||||
content: "";
|
||||
-webkit-transform-origin: center;
|
||||
-ms-transform-origin: center;
|
||||
transform-origin: center;
|
||||
-webkit-animation: rotate 0.8s infinite linear;
|
||||
animation: rotate 0.8s infinite linear;
|
||||
border: 2px solid var(--color-loading-light);
|
||||
border-top-color: var(--color-loading-dark);
|
||||
border-radius: 100%;
|
||||
|
||||
filter: var(--background-invert-if-dark);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,111 @@
|
|||
import { translate as t } from "@nextcloud/l10n";
|
||||
|
||||
/**
|
||||
* Translations file from library source
|
||||
* We also use that to edit the end strings of
|
||||
* some buttons, like resetOperations
|
||||
*
|
||||
* @see https://raw.githubusercontent.com/scaleflex/filerobot-image-editor/v4/packages/react-filerobot-image-editor/src/context/defaultTranslations.js
|
||||
*/
|
||||
export default {
|
||||
name: t("viewer", "Name"),
|
||||
save: t("viewer", "Save"),
|
||||
saveAs: t("viewer", "Save as"),
|
||||
back: t("viewer", "Back"),
|
||||
loading: t("viewer", "Loading …"),
|
||||
// resetOperations: 'Reset/delete all operations',
|
||||
resetOperations: t("viewer", "Reset"),
|
||||
changesLoseConfirmation: t("viewer", "All changes will be lost."),
|
||||
changesLoseConfirmationHint: t(
|
||||
"viewer",
|
||||
"Are you sure you want to continue?"
|
||||
),
|
||||
cancel: t("viewer", "Cancel"),
|
||||
continue: t("viewer", "Continue"),
|
||||
undoTitle: t("viewer", "Undo"),
|
||||
redoTitle: t("viewer", "Redo"),
|
||||
showImageTitle: t("viewer", "Show original image"),
|
||||
zoomInTitle: t("viewer", "Zoom in"),
|
||||
zoomOutTitle: t("viewer", "Zoom out"),
|
||||
toggleZoomMenuTitle: t("viewer", "Toggle zoom menu"),
|
||||
adjustTab: t("viewer", "Adjust"),
|
||||
finetuneTab: t("viewer", "Fine-tune"),
|
||||
filtersTab: t("viewer", "Filters"),
|
||||
watermarkTab: t("viewer", "Watermark"),
|
||||
annotateTab: t("viewer", "Draw"),
|
||||
resize: t("viewer", "Resize"),
|
||||
resizeTab: t("viewer", "Resize"),
|
||||
invalidImageError: t("viewer", "Invalid image."),
|
||||
uploadImageError: t("viewer", "Error while uploading the image."),
|
||||
areNotImages: t("viewer", "are not images"),
|
||||
isNotImage: t("viewer", "is not an image"),
|
||||
toBeUploaded: t("viewer", "to be uploaded"),
|
||||
cropTool: t("viewer", "Crop"),
|
||||
original: t("viewer", "Original"),
|
||||
custom: t("viewer", "Custom"),
|
||||
square: t("viewer", "Square"),
|
||||
landscape: t("viewer", "Landscape"),
|
||||
portrait: t("viewer", "Portrait"),
|
||||
ellipse: t("viewer", "Ellipse"),
|
||||
classicTv: t("viewer", "Classic TV"),
|
||||
cinemascope: t("viewer", "CinemaScope"),
|
||||
arrowTool: t("viewer", "Arrow"),
|
||||
blurTool: t("viewer", "Blur"),
|
||||
brightnessTool: t("viewer", "Brightness"),
|
||||
contrastTool: t("viewer", "Contrast"),
|
||||
ellipseTool: t("viewer", "Ellipse"),
|
||||
unFlipX: t("viewer", "Un-flip X"),
|
||||
flipX: t("viewer", "Flip X"),
|
||||
unFlipY: t("viewer", "Un-flip Y"),
|
||||
flipY: t("viewer", "Flip Y"),
|
||||
hsvTool: t("viewer", "HSV"),
|
||||
hue: t("viewer", "Hue"),
|
||||
saturation: t("viewer", "Saturation"),
|
||||
value: t("viewer", "Value"),
|
||||
imageTool: t("viewer", "Image"),
|
||||
importing: t("viewer", "Importing …"),
|
||||
addImage: t("viewer", "+ Add image"),
|
||||
lineTool: t("viewer", "Line"),
|
||||
penTool: t("viewer", "Pen"),
|
||||
polygonTool: t("viewer", "Polygon"),
|
||||
sides: t("viewer", "Sides"),
|
||||
rectangleTool: t("viewer", "Rectangle"),
|
||||
cornerRadius: t("viewer", "Corner Radius"),
|
||||
resizeWidthTitle: t("viewer", "Width in pixels"),
|
||||
resizeHeightTitle: t("viewer", "Height in pixels"),
|
||||
toggleRatioLockTitle: t("viewer", "Toggle ratio lock"),
|
||||
reset: t("viewer", "Reset"),
|
||||
resetSize: t("viewer", "Reset to original image size"),
|
||||
rotateTool: t("viewer", "Rotate"),
|
||||
textTool: t("viewer", "Text"),
|
||||
textSpacings: t("viewer", "Text spacing"),
|
||||
textAlignment: t("viewer", "Text alignment"),
|
||||
fontFamily: t("viewer", "Font family"),
|
||||
size: t("viewer", "Size"),
|
||||
letterSpacing: t("viewer", "Letter spacing"),
|
||||
lineHeight: t("viewer", "Line height"),
|
||||
warmthTool: t("viewer", "Warmth"),
|
||||
addWatermark: t("viewer", "+ Add watermark"),
|
||||
addWatermarkTitle: t("viewer", "Choose watermark type"),
|
||||
uploadWatermark: t("viewer", "Upload watermark"),
|
||||
addWatermarkAsText: t("viewer", "Add as text"),
|
||||
padding: t("viewer", "Padding"),
|
||||
shadow: t("viewer", "Shadow"),
|
||||
horizontal: t("viewer", "Horizontal"),
|
||||
vertical: t("viewer", "Vertical"),
|
||||
blur: t("viewer", "Blur"),
|
||||
opacity: t("viewer", "Opacity"),
|
||||
position: t("viewer", "Position"),
|
||||
stroke: t("viewer", "Stroke"),
|
||||
saveAsModalLabel: t("viewer", "Save image as"),
|
||||
extension: t("viewer", "Extension"),
|
||||
nameIsRequired: t("viewer", "Name is required."),
|
||||
quality: t("viewer", "Quality"),
|
||||
imageDimensionsHoverTitle: t("viewer", "Saved image size (width x height)"),
|
||||
cropSizeLowerThanResizedWarning: t(
|
||||
"viewer",
|
||||
"Note that the selected crop area is lower than the applied resize which might cause quality decrease"
|
||||
),
|
||||
actualSize: t("viewer", "Actual size (100%)"),
|
||||
fitSize: t("viewer", "Fit size"),
|
||||
};
|
|
@ -5,7 +5,15 @@
|
|||
:class="{ fullyOpened }"
|
||||
:style="{ width: outerWidth }"
|
||||
>
|
||||
<div class="inner" ref="inner">
|
||||
<ImageEditor
|
||||
v-if="editorOpen"
|
||||
:mime="currentPhoto.mimetype"
|
||||
:src="currentDownloadLink"
|
||||
:fileid="currentPhoto.fileid"
|
||||
@close="editorOpen = false"
|
||||
/>
|
||||
|
||||
<div class="inner" ref="inner" v-show="!editorOpen">
|
||||
<div class="top-bar" v-if="photoswipe" :class="{ showControls }">
|
||||
<NcActions
|
||||
:inline="numInlineActions"
|
||||
|
@ -49,6 +57,17 @@
|
|||
<InfoIcon :size="24" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Edit')"
|
||||
v-if="canEdit"
|
||||
@click="openEditor"
|
||||
:close-after-click="true"
|
||||
>
|
||||
{{ t("memories", "Edit") }}
|
||||
<template #icon>
|
||||
<TuneIcon :size="24" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Download')"
|
||||
@click="downloadCurrent"
|
||||
|
@ -77,8 +96,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
|
||||
import { IDay, IPhoto, IRow, IRowType } from "../types";
|
||||
|
||||
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||
|
@ -86,6 +105,8 @@ import { subscribe, unsubscribe } from "@nextcloud/event-bus";
|
|||
import { generateUrl } from "@nextcloud/router";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
|
||||
import ImageEditor from "./ImageEditor.vue";
|
||||
|
||||
import * as dav from "../services/DavRequests";
|
||||
import * as utils from "../services/Utils";
|
||||
import { getPreviewUrl } from "../services/FileUtils";
|
||||
|
@ -104,11 +125,13 @@ import StarOutlineIcon from "vue-material-design-icons/StarOutline.vue";
|
|||
import DownloadIcon from "vue-material-design-icons/Download.vue";
|
||||
import InfoIcon from "vue-material-design-icons/InformationOutline.vue";
|
||||
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
|
||||
import TuneIcon from "vue-material-design-icons/Tune.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
ImageEditor,
|
||||
ShareIcon,
|
||||
DeleteIcon,
|
||||
StarIcon,
|
||||
|
@ -116,6 +139,7 @@ import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
|
|||
DownloadIcon,
|
||||
InfoIcon,
|
||||
OpenInNewIcon,
|
||||
TuneIcon,
|
||||
},
|
||||
})
|
||||
export default class Viewer extends Mixins(GlobalMixin) {
|
||||
|
@ -125,6 +149,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
public isOpen = false;
|
||||
private originalTitle = null;
|
||||
public editorOpen = false;
|
||||
|
||||
private show = false;
|
||||
private showControls = false;
|
||||
|
@ -142,6 +167,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
private globalCount = 0;
|
||||
private globalAnchor = -1;
|
||||
private currIndex = -1;
|
||||
|
||||
mounted() {
|
||||
subscribe("files:sidebar:opened", this.handleAppSidebarOpen);
|
||||
|
@ -155,10 +181,14 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Number of buttons to show inline */
|
||||
get numInlineActions() {
|
||||
let base = 3;
|
||||
if (this.canShare) base++;
|
||||
if (this.canEdit) base++;
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
return 3;
|
||||
return Math.min(base, 3);
|
||||
} else {
|
||||
return 4;
|
||||
return Math.min(base, 5);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -176,17 +206,24 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
}
|
||||
|
||||
/** Get the currently open photo */
|
||||
private getCurrentPhoto() {
|
||||
get currentPhoto() {
|
||||
if (!this.list.length || !this.photoswipe) {
|
||||
return null;
|
||||
}
|
||||
const idx = this.photoswipe.currIndex - this.globalAnchor;
|
||||
const idx = this.currIndex - this.globalAnchor;
|
||||
if (idx < 0 || idx >= this.list.length) {
|
||||
return null;
|
||||
}
|
||||
return this.list[idx];
|
||||
}
|
||||
|
||||
/** Get download link for current photo */
|
||||
get currentDownloadLink() {
|
||||
return this.currentPhoto
|
||||
? window.location.origin + getDownloadLink(this.currentPhoto)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Create the base photoswipe object */
|
||||
private async createBase(args: PhotoSwipeOptions) {
|
||||
this.show = true;
|
||||
|
@ -298,6 +335,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
// Update vue route for deep linking
|
||||
this.photoswipe.on("slideActivate", (e) => {
|
||||
this.currIndex = this.photoswipe.currIndex;
|
||||
this.setRouteHash(e.slide?.data?.photo);
|
||||
this.updateTitle(e.slide?.data?.photo);
|
||||
});
|
||||
|
@ -312,6 +350,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
// Create video element
|
||||
content.videoElement = document.createElement("video") as any;
|
||||
content.videoElement.setAttribute("preload", "none");
|
||||
content.videoElement.classList.add("video-js");
|
||||
|
||||
// Get DAV URL for video
|
||||
|
@ -332,7 +371,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
fluid: true,
|
||||
autoplay: content.data.playvideo,
|
||||
controls: true,
|
||||
preload: "metadata",
|
||||
preload: "none",
|
||||
muted: true,
|
||||
html5: {
|
||||
vhs: {
|
||||
|
@ -557,6 +596,16 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
}
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return this.currentPhoto?.mimetype === "image/jpeg";
|
||||
}
|
||||
|
||||
private openEditor() {
|
||||
// Only for JPEG for now
|
||||
if (!this.canEdit) return;
|
||||
this.editorOpen = true;
|
||||
}
|
||||
|
||||
/** Does the browser support native share API */
|
||||
get canShare() {
|
||||
return "share" in navigator;
|
||||
|
@ -573,7 +622,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
if (!img?.src) return;
|
||||
|
||||
// Shre image data using navigator api
|
||||
const photo = this.getCurrentPhoto();
|
||||
const photo = this.currentPhoto;
|
||||
if (!photo) return;
|
||||
|
||||
// No videos yet
|
||||
|
@ -626,14 +675,14 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Is the current photo a favorite */
|
||||
private isFavorite() {
|
||||
const p = this.getCurrentPhoto();
|
||||
const p = this.currentPhoto;
|
||||
if (!p) return false;
|
||||
return Boolean(p.flag & this.c.FLAG_IS_FAVORITE);
|
||||
}
|
||||
|
||||
/** Favorite the current photo */
|
||||
private async favoriteCurrent() {
|
||||
const photo = this.getCurrentPhoto();
|
||||
const photo = this.currentPhoto;
|
||||
const val = !this.isFavorite();
|
||||
try {
|
||||
this.updateLoading(1);
|
||||
|
@ -655,14 +704,14 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Download the current photo */
|
||||
private async downloadCurrent() {
|
||||
const photo = this.getCurrentPhoto();
|
||||
const photo = this.currentPhoto;
|
||||
if (!photo) return;
|
||||
dav.downloadFilesByPhotos([photo]);
|
||||
}
|
||||
|
||||
/** Open the sidebar */
|
||||
private async openSidebar(photo?: IPhoto) {
|
||||
const fInfo = await dav.getFiles([photo || this.getCurrentPhoto()]);
|
||||
const fInfo = await dav.getFiles([photo || this.currentPhoto]);
|
||||
globalThis.OCA?.Files?.Sidebar?.setFullScreenMode?.(true);
|
||||
globalThis.OCA.Files.Sidebar.open(fInfo[0].filename);
|
||||
}
|
||||
|
@ -720,8 +769,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
* Open the files app with the current file.
|
||||
*/
|
||||
private async viewInFolder() {
|
||||
const photo = this.getCurrentPhoto();
|
||||
if (photo) dav.viewInFolder(photo);
|
||||
if (this.currentPhoto) dav.viewInFolder(this.currentPhoto);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -98,9 +98,9 @@ export function getDownloadLink(photo: IPhoto) {
|
|||
route.params.name
|
||||
);
|
||||
if (fInfos.length) {
|
||||
return `remote.php/dav/${fInfos[0].originalFilename}`;
|
||||
return `/remote.php/dav/${fInfos[0].originalFilename}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `remote.php/dav/${photo.filename}`; // normal route
|
||||
return `/remote.php/dav/${photo.filename}`; // normal route
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue