✨ add image editing features
This commit is contained in:
74
src/components/FilerobotImageEditor/FilerobotImageEditor.vue
Normal file
74
src/components/FilerobotImageEditor/FilerobotImageEditor.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div ref="fileRobotImageRef" class="editor-container"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import FilerobotImageEditor from '@/assets/lib/ImageEditor/filerobot-image-editor.min.js';
|
||||
import type {IFilerobotImageEditorConfig} from '@/types/imageEditorConfig.ts';
|
||||
import fr from './lang/fr';
|
||||
import en from './lang/en';
|
||||
import zh from './lang/zh';
|
||||
|
||||
const fileRobotImageRef = ref(null);
|
||||
|
||||
interface IProps {
|
||||
config: IFilerobotImageEditorConfig
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>();
|
||||
|
||||
/**
|
||||
* Helper: Determines translations based on language or passed configuration.
|
||||
* @returns Translation strings object.
|
||||
*/
|
||||
const getTranslations = () => {
|
||||
if (props.config?.translations) {
|
||||
return props.config.translations;
|
||||
}
|
||||
if (!props.config?.translations && props.config?.language === 'fr') {
|
||||
return fr;
|
||||
}
|
||||
if (!props.config?.translations && props.config?.language === 'en') {
|
||||
return en;
|
||||
}
|
||||
return zh;
|
||||
};
|
||||
/**
|
||||
* Lifecycle: Mounts the editor instance on the container.
|
||||
*/
|
||||
const filerobotImageEditorInstance = ref(null);
|
||||
|
||||
const initializeEditor = () => {
|
||||
try {
|
||||
if (!fileRobotImageRef.value) {
|
||||
throw new Error('fileRobotImageRef is not available');
|
||||
}
|
||||
|
||||
const instance = new FilerobotImageEditor(fileRobotImageRef.value, {
|
||||
...props.config,
|
||||
translations: getTranslations(),
|
||||
});
|
||||
|
||||
filerobotImageEditorInstance.value = instance;
|
||||
instance.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize FilerobotImageEditor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
filerobotImageEditorInstance: computed(() => filerobotImageEditorInstance.value),
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.editor-container {
|
||||
height: 75vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
96
src/components/FilerobotImageEditor/lang/en.ts
Normal file
96
src/components/FilerobotImageEditor/lang/en.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export default {
|
||||
name: 'Name',
|
||||
save: 'Save',
|
||||
saveAs: 'Save as',
|
||||
back: 'Back',
|
||||
loading: 'Loading...',
|
||||
resetOperations: 'Reset/delete all operations',
|
||||
changesLoseConfirmation: 'All changes will be lost',
|
||||
changesLoseConfirmationHint: 'Are you sure you want to continue?',
|
||||
cancel: 'Cancel',
|
||||
continue: 'Continue',
|
||||
undoTitle: 'Undo last operation',
|
||||
redoTitle: 'Redo last operation',
|
||||
showImageTitle: 'Show original image',
|
||||
zoomInTitle: 'Zoom in',
|
||||
zoomOutTitle: 'Zoom out',
|
||||
toggleZoomMenuTitle: 'Toggle zoom menu',
|
||||
adjustTab: 'Adjust',
|
||||
finetuneTab: 'Finetune',
|
||||
filtersTab: 'Filters',
|
||||
watermarkTab: 'Watermark',
|
||||
annotateTab: 'Draw',
|
||||
resize: 'Resize',
|
||||
resizeTab: 'Resize',
|
||||
invalidImageError: 'Invalid image provided.',
|
||||
uploadImageError: 'Error while uploading the image.',
|
||||
areNotImages: 'are not images',
|
||||
isNotImage: 'is not image',
|
||||
toBeUploaded: 'to be uploaded',
|
||||
cropTool: 'Crop',
|
||||
original: 'Original',
|
||||
custom: 'Custom',
|
||||
square: 'Square',
|
||||
landscape: 'Landscape',
|
||||
portrait: 'Portrait',
|
||||
ellipse: 'Ellipse',
|
||||
classicTv: 'Classic TV',
|
||||
cinemascope: 'Cinemascope',
|
||||
arrowTool: 'Arrow',
|
||||
blurTool: 'Blur',
|
||||
brightnessTool: 'Brightness',
|
||||
contrastTool: 'Contrast',
|
||||
ellipseTool: 'Ellipse',
|
||||
unFlipX: 'Un-Flip X',
|
||||
flipX: 'Flip X',
|
||||
unFlipY: 'Un-Flip Y',
|
||||
flipY: 'Flip Y',
|
||||
hsvTool: 'HSV',
|
||||
hue: 'Hue',
|
||||
saturation: 'Saturation',
|
||||
value: 'Value',
|
||||
imageTool: 'Image',
|
||||
importing: 'Importing...',
|
||||
addImage: '+ Add image',
|
||||
lineTool: 'Line',
|
||||
penTool: 'Pen',
|
||||
polygonTool: 'Polygon',
|
||||
sides: 'Sides',
|
||||
rectangleTool: 'Rectangle',
|
||||
cornerRadius: 'Corner Radius',
|
||||
resizeWidthTitle: 'Width in pixels',
|
||||
resizeHeightTitle: 'Height in pixels',
|
||||
toggleRatioLockTitle: 'Toggle ratio lock',
|
||||
reset: 'Reset',
|
||||
resetSize: 'Reset to original image size',
|
||||
rotateTool: 'Rotate',
|
||||
textTool: 'Text',
|
||||
textSpacings: 'Text spacings',
|
||||
textAlignment: 'Text alignment',
|
||||
fontFamily: 'Font family',
|
||||
size: 'Size',
|
||||
letterSpacing: 'Letter Spacing',
|
||||
lineHeight: 'Line height',
|
||||
warmthTool: 'Warmth',
|
||||
addWatermark: '+ Add watermark',
|
||||
addWatermarkTitle: 'Choose the watermark type',
|
||||
uploadWatermark: 'Upload watermark',
|
||||
addWatermarkAsText: 'Add as text',
|
||||
padding: 'Padding',
|
||||
shadow: 'Shadow',
|
||||
horizontal: 'Horizontal',
|
||||
vertical: 'Vertical',
|
||||
blur: 'Blur',
|
||||
opacity: 'Opacity',
|
||||
position: 'Position',
|
||||
stroke: 'Stroke',
|
||||
saveAsModalLabel: 'Save the image as',
|
||||
extension: 'Extension',
|
||||
nameIsRequired: 'Name is required.',
|
||||
quality: 'Quality',
|
||||
imageDimensionsHoverTitle: 'Saved image size (width x height)',
|
||||
cropSizeLowerThanResizedWarning:
|
||||
'Note, the selected crop area is lower than the applied resize which might cause quality decrease',
|
||||
actualSize: 'Actual size (100%)',
|
||||
fitSize: 'Fit size',
|
||||
};
|
96
src/components/FilerobotImageEditor/lang/fr.ts
Normal file
96
src/components/FilerobotImageEditor/lang/fr.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export default {
|
||||
name: 'Nom',
|
||||
save: 'Enregistrer',
|
||||
saveAs: 'Enregistrer sous',
|
||||
back: 'Retour',
|
||||
loading: 'Chargement...',
|
||||
resetOperations: 'Réinitialiser/supprimer toutes les opérations',
|
||||
changesLoseConfirmation: 'Tous les changements seront perdus',
|
||||
changesLoseConfirmationHint: 'Êtes-vous sûr de vouloir continuer ?',
|
||||
cancel: 'Annuler',
|
||||
continue: 'Continuer',
|
||||
undoTitle: 'Annuler la dernière opération',
|
||||
redoTitle: 'Refaire la dernière opération',
|
||||
showImageTitle: "Afficher l'image originale",
|
||||
zoomInTitle: 'Zoom avant',
|
||||
zoomOutTitle: 'Zoom arrière',
|
||||
toggleZoomMenuTitle: 'Afficher/masquer le menu de zoom',
|
||||
adjustTab: 'Ajuster',
|
||||
finetuneTab: 'Affiner',
|
||||
filtersTab: 'Filtres',
|
||||
watermarkTab: 'Filigrane',
|
||||
annotateTab: 'Dessiner',
|
||||
resize: 'Rétréci',
|
||||
resizeTab: 'Rétréci',
|
||||
invalidImageError: 'Image non valide fournie.',
|
||||
uploadImageError: "Erreur lors du téléchargement de l'image.",
|
||||
areNotImages: 'ne sont pas des images',
|
||||
isNotImage: "n'est pas une image",
|
||||
toBeUploaded: 'à télécharger',
|
||||
cropTool: 'Rogner',
|
||||
original: 'Original',
|
||||
custom: 'Personnalisé',
|
||||
square: 'Carré',
|
||||
landscape: 'Paysage',
|
||||
portrait: 'Portrait',
|
||||
ellipse: 'Ellipse',
|
||||
classicTv: 'TV classique',
|
||||
cinemascope: 'Cinémascope',
|
||||
arrowTool: 'Flèche',
|
||||
blurTool: 'Flou',
|
||||
brightnessTool: 'Luminosité',
|
||||
contrastTool: 'Contraste',
|
||||
ellipseTool: 'Ellipse',
|
||||
unFlipX: 'Annuler le retournement X',
|
||||
flipX: 'Retourner X',
|
||||
unFlipY: 'Annuler le retournement Y',
|
||||
flipY: 'Retourner Y',
|
||||
hsvTool: 'HSV',
|
||||
hue: 'Teinte',
|
||||
saturation: 'Saturation',
|
||||
value: 'Valeur',
|
||||
imageTool: 'Image',
|
||||
importing: 'Importation...',
|
||||
addImage: '+ Ajouter une image',
|
||||
lineTool: 'Ligne',
|
||||
penTool: 'Stylo',
|
||||
polygonTool: 'Polygone',
|
||||
sides: 'Côtés',
|
||||
rectangleTool: 'Rectangle',
|
||||
cornerRadius: 'Rayon des coins',
|
||||
resizeWidthTitle: 'Largeur en pixels',
|
||||
resizeHeightTitle: 'Hauteur en pixels',
|
||||
toggleRatioLockTitle: 'Activer/désactiver le verrouillage des proportions',
|
||||
reset: 'Réinitialiser',
|
||||
resetSize: "Réinitialiser à la taille originale de l'image",
|
||||
rotateTool: 'Pivoter',
|
||||
textTool: 'Texte',
|
||||
textSpacings: 'Espacement du texte',
|
||||
textAlignment: 'Alignement du texte',
|
||||
fontFamily: 'Famille de police',
|
||||
size: 'Taille',
|
||||
letterSpacing: 'Espacement des lettres',
|
||||
lineHeight: 'Interligne',
|
||||
warmthTool: 'Chaleur',
|
||||
addWatermark: '+ Ajouter un filigrane',
|
||||
addWatermarkTitle: 'Choisir le type de filigrane',
|
||||
uploadWatermark: 'Télécharger un filigrane',
|
||||
addWatermarkAsText: 'Ajouter en tant que texte',
|
||||
padding: 'Marge intérieure',
|
||||
shadow: 'Ombre',
|
||||
horizontal: 'Horizontal',
|
||||
vertical: 'Vertical',
|
||||
blur: 'Flou',
|
||||
opacity: 'Opacité',
|
||||
position: 'Position',
|
||||
stroke: 'Contour',
|
||||
saveAsModalLabel: "Enregistrer l'image sous",
|
||||
extension: 'Extension',
|
||||
nameIsRequired: 'Le nom est requis.',
|
||||
quality: 'Qualité',
|
||||
imageDimensionsHoverTitle: "Taille de l'image sauvegardée (largeur x hauteur)",
|
||||
cropSizeLowerThanResizedWarning:
|
||||
'Attention, la zone de recadrage sélectionnée est inférieure au redimensionnement appliqué, ce qui pourrait entraîner une perte de qualité.',
|
||||
actualSize: 'Taille réelle (100%)',
|
||||
fitSize: 'Adapter à la taille',
|
||||
};
|
95
src/components/FilerobotImageEditor/lang/zh.ts
Normal file
95
src/components/FilerobotImageEditor/lang/zh.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export default {
|
||||
name: '名称',
|
||||
save: '保存',
|
||||
saveAs: '另存为',
|
||||
back: '返回',
|
||||
loading: '加载中...',
|
||||
resetOperations: '重置/删除所有操作',
|
||||
changesLoseConfirmation: '所有更改将会丢失',
|
||||
changesLoseConfirmationHint: '确定要继续吗?',
|
||||
cancel: '取消',
|
||||
continue: '继续',
|
||||
undoTitle: '撤销上一步操作',
|
||||
redoTitle: '重做上一步操作',
|
||||
showImageTitle: '显示原始图片',
|
||||
zoomInTitle: '放大',
|
||||
zoomOutTitle: '缩小',
|
||||
toggleZoomMenuTitle: '切换缩放菜单',
|
||||
adjustTab: '基础调整',
|
||||
finetuneTab: '精细调整',
|
||||
filtersTab: '滤镜',
|
||||
watermarkTab: '水印',
|
||||
annotateTab: '标注',
|
||||
resize: '调整尺寸',
|
||||
resizeTab: '尺寸调整',
|
||||
invalidImageError: '提供的图片无效',
|
||||
uploadImageError: '图片上传时发生错误',
|
||||
areNotImages: '不是图片文件',
|
||||
isNotImage: '非图片文件',
|
||||
toBeUploaded: '待上传',
|
||||
cropTool: '裁剪工具',
|
||||
original: '原始比例',
|
||||
custom: '自定义',
|
||||
square: '正方形',
|
||||
landscape: '横屏比例',
|
||||
portrait: '竖屏比例',
|
||||
ellipse: '椭圆形',
|
||||
classicTv: '经典电视比例',
|
||||
cinemascope: '宽银幕比例',
|
||||
arrowTool: '箭头工具',
|
||||
blurTool: '模糊工具',
|
||||
brightnessTool: '亮度',
|
||||
contrastTool: '对比度',
|
||||
ellipseTool: '椭圆工具',
|
||||
unFlipX: '取消水平翻转',
|
||||
flipX: '水平翻转',
|
||||
unFlipY: '取消垂直翻转',
|
||||
flipY: '垂直翻转',
|
||||
hsvTool: 'HSV调整',
|
||||
hue: '色相',
|
||||
saturation: '饱和度',
|
||||
value: '明度',
|
||||
imageTool: '图片工具',
|
||||
importing: '导入中...',
|
||||
addImage: '+ 添加图片',
|
||||
lineTool: '直线工具',
|
||||
penTool: '画笔工具',
|
||||
polygonTool: '多边形工具',
|
||||
sides: '边数',
|
||||
rectangleTool: '矩形工具',
|
||||
cornerRadius: '圆角半径',
|
||||
resizeWidthTitle: '宽度(像素)',
|
||||
resizeHeightTitle: '高度(像素)',
|
||||
toggleRatioLockTitle: '锁定宽高比',
|
||||
reset: '重置',
|
||||
resetSize: '恢复原始尺寸',
|
||||
rotateTool: '旋转工具',
|
||||
textTool: '文本工具',
|
||||
textSpacings: '文字间距',
|
||||
textAlignment: '文字对齐',
|
||||
fontFamily: '字体',
|
||||
size: '字号',
|
||||
letterSpacing: '字间距',
|
||||
lineHeight: '行高',
|
||||
warmthTool: '色温调整',
|
||||
addWatermark: '+ 添加水印',
|
||||
addWatermarkTitle: '选择水印类型',
|
||||
uploadWatermark: '上传水印图片',
|
||||
addWatermarkAsText: '添加文字水印',
|
||||
padding: '内边距',
|
||||
shadow: '阴影',
|
||||
horizontal: '水平',
|
||||
vertical: '垂直',
|
||||
blur: '模糊度',
|
||||
opacity: '不透明度',
|
||||
position: '位置',
|
||||
stroke: '描边',
|
||||
saveAsModalLabel: '将图片另存为',
|
||||
extension: '扩展名',
|
||||
nameIsRequired: '名称不能为空',
|
||||
quality: '画质',
|
||||
imageDimensionsHoverTitle: '保存图片尺寸(宽 x 高)',
|
||||
cropSizeLowerThanResizedWarning: '注意:所选裁剪区域小于调整后的尺寸,可能会导致画质下降',
|
||||
actualSize: '实际尺寸(100%)',
|
||||
fitSize: '适应尺寸',
|
||||
};
|
@@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<a-upload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<a-button icon="upload" class="upload-button">Upload Image</a-button>
|
||||
</a-upload>
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<a-button @click="undo">撤消</a-button>
|
||||
<a-button @click="redo">重做</a-button>
|
||||
<a-button @click="flipHorizontal">水平翻转</a-button>
|
||||
<a-button @click="rotate">旋转</a-button>
|
||||
<a-button @click="addText">添加文本</a-button>
|
||||
<a-button @click="applyFilter">应用过滤器</a-button>
|
||||
<!-- Add more buttons for other features -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref, watch } from 'vue';
|
||||
import * as fabric from 'fabric';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ImageEditor',
|
||||
props: {
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let fabricCanvas: fabric.Canvas;
|
||||
const history = ref<any[]>([]);
|
||||
const redoStack = ref<any[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
if (canvas.value) {
|
||||
fabricCanvas = new fabric.Canvas(canvas.value);
|
||||
fabricCanvas.on('object:modified', saveState);
|
||||
fabricCanvas.on('object:added', saveState);
|
||||
|
||||
// Add mouse wheel zoom functionality
|
||||
fabricCanvas.on('mouse:wheel', (opt) => {
|
||||
const delta = opt.e.deltaY;
|
||||
let zoom = fabricCanvas.getZoom();
|
||||
zoom *= 0.999 ** delta;
|
||||
if (zoom > 20) zoom = 20;
|
||||
if (zoom < 0.01) zoom = 0.01;
|
||||
fabricCanvas.zoomToPoint(new fabric.Point(opt.e.offsetX, opt.e.offsetY), zoom);
|
||||
opt.e.preventDefault();
|
||||
opt.e.stopPropagation();
|
||||
});
|
||||
|
||||
loadImage(props.imageUrl);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.imageUrl, (newUrl) => {
|
||||
loadImage(newUrl);
|
||||
});
|
||||
|
||||
const loadImage = async (url: string) => {
|
||||
try {
|
||||
const img = await fabric.Image.fromURL(url, { crossOrigin: 'anonymous' });
|
||||
fabricCanvas.clear();
|
||||
img.set({
|
||||
left: fabricCanvas.getWidth() / 2,
|
||||
top: fabricCanvas.getHeight() / 2,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
scaleX: fabricCanvas.getWidth() / img.width!,
|
||||
scaleY: fabricCanvas.getHeight() / img.height!,
|
||||
});
|
||||
fabricCanvas.add(img);
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
} catch (error) {
|
||||
console.error('Error loading image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveState = () => {
|
||||
redoStack.value = [];
|
||||
history.value.push(JSON.stringify(fabricCanvas));
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imgObj = new Image();
|
||||
imgObj.src = e.target?.result as string;
|
||||
imgObj.onload = () => {
|
||||
fabricCanvas.clear();
|
||||
const img = new fabric.Image(imgObj, {
|
||||
left: fabricCanvas.getWidth() / 2,
|
||||
top: fabricCanvas.getHeight() / 2,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
scaleX: fabricCanvas.getWidth() / imgObj.width,
|
||||
scaleY: fabricCanvas.getHeight() / imgObj.height,
|
||||
});
|
||||
fabricCanvas.add(img);
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
};
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
return false;
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (history.value.length > 1) {
|
||||
redoStack.value.push(history.value.pop()!);
|
||||
fabricCanvas.loadFromJSON(history.value[history.value.length - 1], () => {
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
if (redoStack.value.length > 0) {
|
||||
const state = redoStack.value.pop()!;
|
||||
history.value.push(state);
|
||||
fabricCanvas.loadFromJSON(state, () => {
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const flipHorizontal = () => {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (activeObject) {
|
||||
activeObject.toggle('flipX');
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
}
|
||||
};
|
||||
|
||||
const rotate = () => {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (activeObject) {
|
||||
activeObject.rotate((activeObject.angle || 0) + 90);
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
}
|
||||
};
|
||||
|
||||
const addText = () => {
|
||||
const text = new fabric.Textbox('Edit me', {
|
||||
left: fabricCanvas.getWidth() / 2,
|
||||
top: fabricCanvas.getHeight() / 2,
|
||||
width: 200,
|
||||
fontSize: 20,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
editable: true,
|
||||
});
|
||||
fabricCanvas.add(text);
|
||||
fabricCanvas.setActiveObject(text);
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
const activeObject = fabricCanvas.getActiveObject();
|
||||
if (activeObject && activeObject instanceof fabric.Image) {
|
||||
activeObject.filters = activeObject.filters || [];
|
||||
activeObject.filters.push(new fabric.Image.filters.Grayscale());
|
||||
activeObject.applyFilters();
|
||||
fabricCanvas.renderAll();
|
||||
saveState();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canvas,
|
||||
handleBeforeUpload,
|
||||
undo,
|
||||
redo,
|
||||
flipHorizontal,
|
||||
rotate,
|
||||
addText,
|
||||
applyFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
margin-bottom: 20px;
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controls a-button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.controls a-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
</style>
|
||||
|
@@ -20,9 +20,36 @@
|
||||
<template #icon>
|
||||
<PlusSquareOutlined class="photo-toolbar-icon"/>
|
||||
</template>
|
||||
添加到
|
||||
<APopover placement="bottom" trigger="click">
|
||||
<template #content>
|
||||
<div class="add-to-popover">
|
||||
<div class="add-to-popover-top">
|
||||
<AInput autofocus placeholder="搜索相册" v-model:value="searchValue"
|
||||
@pressEnter="(e)=>searchAlbum(e.target.value)">
|
||||
<template #suffix>
|
||||
<AAvatar size="small" shape="circle" :src="search" @click="searchAlbum(searchValue)"/>
|
||||
</template>
|
||||
</AInput>
|
||||
</div>
|
||||
<div class="add-to-popover-bottom">
|
||||
<div class="add-to-popover-album-item"
|
||||
@click="addImagesToAlbum(album.id)"
|
||||
v-for="(album, index) in albumList" :key="index">
|
||||
<div>
|
||||
<AAvatar :size="50" shape="square" :src="album.cover_image?album.cover_image:defaultAlbumCover"/>
|
||||
</div>
|
||||
<div class="add-to-popover-album-item-name">
|
||||
<span style="font-size: 14px;font-weight: bold">{{ album.name }}</span>
|
||||
<ATag color="blue" :bordered="false">{{ album.created_at }}</ATag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
添加到
|
||||
</APopover>
|
||||
</AButton>
|
||||
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">
|
||||
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn" @click="downloadImages">
|
||||
<template #icon>
|
||||
<DownloadOutlined class="photo-toolbar-icon"/>
|
||||
</template>
|
||||
@@ -34,12 +61,12 @@
|
||||
</template>
|
||||
编辑
|
||||
</AButton>
|
||||
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">
|
||||
<template #icon>
|
||||
<ShareAltOutlined class="photo-toolbar-icon"/>
|
||||
</template>
|
||||
分享
|
||||
</AButton>
|
||||
<!-- <AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">-->
|
||||
<!-- <template #icon>-->
|
||||
<!-- <ShareAltOutlined class="photo-toolbar-icon"/>-->
|
||||
<!-- </template>-->
|
||||
<!-- 分享-->
|
||||
<!-- </AButton>-->
|
||||
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn" @click="deleteImages">
|
||||
<template #icon>
|
||||
<DeleteOutlined class="photo-toolbar-icon"/>
|
||||
@@ -49,18 +76,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<AModal v-model:open="imageStore.imageEditVisible" title="编辑图片" width="50%" :mask-closable="false"
|
||||
:keyboard="false" :wrap-class-name="'image-edit-modal'">
|
||||
<ImageEditor :image-url="getUrlById(props.selected[0])"/>
|
||||
<AModal v-model:open="imageStore.imageEditVisible" width="60%" :mask-closable="false"
|
||||
:keyboard="false" wrap-class-name="full-modal" :footer="null" @cancel="handleImageEditClose">
|
||||
<FilerobotImageEditor class="editor-container" :config="config"/>
|
||||
</AModal>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
import FilerobotImageEditor from '@/components/FilerobotImageEditor/FilerobotImageEditor.vue';
|
||||
import useStore from "@/store";
|
||||
import {Image, ImageList, ImageRecord} from "@/types/image";
|
||||
import {deletedImagesApi} from "@/api/storage";
|
||||
import {ImageList, ImageRecord} from "@/types/image";
|
||||
import {albumListApi, albumSearchApi, deletedImagesApi, imageToAlbumApi} from "@/api/storage";
|
||||
import {message} from "ant-design-vue";
|
||||
import ImageEditor from "@/components/ImageEditor/ImageEditor.vue";
|
||||
import {downloadImagesAsZip} from "@/utils/imageUtils/downloadImagesAsZip.ts";
|
||||
import search from "@/assets/svgs/search.svg";
|
||||
import confidential from '@/assets/images/confidential.png';
|
||||
import topSecret from '@/assets/images/top-secret.png';
|
||||
import {type IFilerobotImageEditorConfig, TABS, TOOLS} from '@/types/imageEditorConfig.ts';
|
||||
import defaultAlbumCover from "@/assets/images/default-cover.png";
|
||||
|
||||
const props = defineProps({
|
||||
selected: {
|
||||
@@ -77,11 +109,83 @@ const uploadStore = useStore().upload;
|
||||
const clearSelected = () => {
|
||||
imageStore.selected = [];
|
||||
};
|
||||
const image = new Image();
|
||||
|
||||
const selectAll = () => {
|
||||
imageStore.selected = props.imageList.flatMap((record: ImageRecord) => record.list.map((image: Image) => image.id));
|
||||
/**
|
||||
* Download file
|
||||
* @param base64Data
|
||||
* @param fileName
|
||||
*/
|
||||
function uriDownload(base64Data: string, fileName: string) {
|
||||
// Create a link element
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = base64Data;
|
||||
link.download = fileName;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据id 列表获取url 列表
|
||||
*/
|
||||
const idToUrlMap = computed(() => {
|
||||
const map: { [key: number]: string } = {};
|
||||
props.imageList.forEach((record: ImageRecord) => {
|
||||
record.list.forEach((image: any) => {
|
||||
map[image.id] = image.url;
|
||||
});
|
||||
});
|
||||
return map;
|
||||
});
|
||||
/**
|
||||
* 根据id 获取url
|
||||
* @param id
|
||||
*/
|
||||
const getUrlById = (id: number): string => {
|
||||
return idToUrlMap.value[id];
|
||||
};
|
||||
const config: IFilerobotImageEditorConfig = {
|
||||
source: image,
|
||||
[TOOLS.WATERMARK]: {
|
||||
gallery: [confidential, topSecret],
|
||||
textScalingRatio: 0.33,
|
||||
imageScalingRatio: 0.33,
|
||||
},
|
||||
[TOOLS.TEXT]: {
|
||||
text: '双击编辑文本...',
|
||||
},
|
||||
language: 'zh',
|
||||
avoidChangesNotSavedAlertOnLeave: false,
|
||||
// tabsIds: [TABS.ADJUST, TABS.ANNOTATE, TABS.FINETUNE, TABS.WATERMARK],
|
||||
previewPixelRatio: window.devicePixelRatio * 4,
|
||||
defaultTabId: TABS.ADJUST,
|
||||
onSave: (savedImageData) => {
|
||||
try {
|
||||
uriDownload(savedImageData.imageBase64!, savedImageData.fullName!);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
defaultSavedImageName: 'image',
|
||||
showBackButton: false,
|
||||
showSaveButton: true,
|
||||
disableSaveIfNoChanges: true,
|
||||
resetOnImageSourceChange: true,
|
||||
closeAfterSave: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 全选
|
||||
*/
|
||||
const selectAll = () => {
|
||||
imageStore.selected = props.imageList.flatMap((record: ImageRecord) => record.list.map((image: any) => image.id));
|
||||
};
|
||||
/**
|
||||
* 删除图片
|
||||
*/
|
||||
const deleteImages = async () => {
|
||||
imageStore.imageListLoading = true;
|
||||
const res: any = await deletedImagesApi(props.selected, uploadStore.storageSelected?.[0], uploadStore.storageSelected?.[1]);
|
||||
@@ -96,32 +200,99 @@ const isAllSelected = computed(() => {
|
||||
const imageList = props.imageList || [];
|
||||
return props.selected.length === imageList.flatMap((record: ImageRecord) => record.list).length;
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 编辑图片
|
||||
*/
|
||||
const editImages = () => {
|
||||
const editImages = async () => {
|
||||
// 只能编辑一张图片
|
||||
if (props.selected.length !== 1) {
|
||||
message.warning("只能编辑一张图片");
|
||||
return;
|
||||
}
|
||||
imageStore.imageEditVisible = true;
|
||||
image.crossOrigin = 'Anonymous';
|
||||
image.src = getUrlById(props.selected[0]);
|
||||
image.onload = () => {
|
||||
imageStore.imageEditVisible = true;
|
||||
};
|
||||
};
|
||||
|
||||
const idToUrlMap = computed(() => {
|
||||
const map: { [key: number]: string } = {};
|
||||
props.imageList.forEach((record: ImageRecord) => {
|
||||
record.list.forEach((image: Image) => {
|
||||
map[image.id] = image.url;
|
||||
});
|
||||
});
|
||||
return map;
|
||||
|
||||
// 根据id selected 列表获取url 列表
|
||||
const selectedUrlList = computed(() => {
|
||||
return props.selected.map(id => idToUrlMap.value[id]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*/
|
||||
function downloadImages() {
|
||||
if (selectedUrlList.value.length === 0) {
|
||||
message.warning("请选择图片");
|
||||
return;
|
||||
}
|
||||
downloadImagesAsZip(selectedUrlList.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const albumList = ref<any[]>([]);
|
||||
|
||||
async function getAlbumList(type: number = 0, sort: boolean = true) {
|
||||
const res: any = await albumListApi(type, sort);
|
||||
if (res && res.code === 200) {
|
||||
albumList.value = res.data.albums;
|
||||
}
|
||||
}
|
||||
|
||||
const searchValue = ref<string>("");
|
||||
|
||||
/**
|
||||
* 搜索相册
|
||||
* @param keyword
|
||||
*/
|
||||
async function searchAlbum(keyword: string) {
|
||||
if (keyword.trim() === "") {
|
||||
await getAlbumList();
|
||||
return;
|
||||
}
|
||||
const res: any = await albumSearchApi(keyword);
|
||||
if (res && res.code === 200) {
|
||||
albumList.value = res.data.albums;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到相册
|
||||
* @param albumId
|
||||
*/
|
||||
async function addImagesToAlbum(albumId: number) {
|
||||
if (props.selected.length === 0) {
|
||||
message.warning("请选择图片");
|
||||
return;
|
||||
}
|
||||
const res: any = await imageToAlbumApi(props.selected, albumId, uploadStore.storageSelected?.[0], uploadStore.storageSelected?.[1]);
|
||||
if (res && res.code === 200) {
|
||||
message.success("添加成功");
|
||||
imageStore.selected = [];
|
||||
} else {
|
||||
message.error("添加失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭编辑图片弹窗
|
||||
*/
|
||||
function handleImageEditClose() {
|
||||
imageStore.selected = [];
|
||||
image.src = '';
|
||||
imageStore.imageEditVisible = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getAlbumList();
|
||||
});
|
||||
|
||||
const getUrlById = (id: number): string => {
|
||||
console.log(idToUrlMap.value[id]);
|
||||
return idToUrlMap.value[id];
|
||||
};
|
||||
onBeforeUnmount(() => {
|
||||
imageStore.selected = [];
|
||||
});
|
||||
@@ -168,7 +339,7 @@ onBeforeUnmount(() => {
|
||||
.photo-toolbar-icon {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.photo-toolbar-btn {
|
||||
@@ -199,4 +370,60 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.add-to-popover {
|
||||
width: 230px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
.add-to-popover-top {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.add-to-popover-bottom {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: auto;
|
||||
gap: 10px;
|
||||
|
||||
.add-to-popover-album-item {
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(191, 189, 189, 0.11);
|
||||
border: 1px solid #ddd; // 添加一圈线
|
||||
transition: transform 0.2s, box-shadow 0.2s; // 添加过渡效果
|
||||
&:hover {
|
||||
transform: scale(1.05); // 鼠标悬停时放大
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); // 鼠标悬停时添加阴影
|
||||
background-color: rgba(129, 193, 214, 0.11);
|
||||
}
|
||||
|
||||
.add-to-popover-album-item-name {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,11 +1,19 @@
|
||||
<template>
|
||||
<Spin size="middle" :spinning="imageStore.imageListLoading" indicator="spin-dot" tip="loading..." :rotate="true">
|
||||
<div style="width:100%;height:100%;" v-if="props.imageList">
|
||||
<div v-for="(itemList, indexList) in props.imageList" :key="indexList">
|
||||
<span style="margin-left: 10px;font-size: 13px">{{ itemList.date }}</span>
|
||||
<div v-for="(itemList, indexList) in props.imageList" :key="indexList" class="group-container"
|
||||
:class="{ 'has-selected': hasSelected(itemList) }">
|
||||
<div class="date-header">
|
||||
<img :src="getGroupIcon(itemList)" alt="Hover" class="custom-checkbox"
|
||||
:style="{ width: iconSize + 'px', height: iconSize + 'px' }"
|
||||
@click.stop="toggleGroup(itemList)"
|
||||
/>
|
||||
<span class="date-text">{{ itemList.date }}</span>
|
||||
</div>
|
||||
<AImagePreviewGroup>
|
||||
<div class="photo-list">
|
||||
<div v-for="(item, index) in itemList.list" :key="index">
|
||||
<div v-for="(item, index) in itemList.list" :key="index"
|
||||
:class="{'photo-item': true, 'animate': true, [`animate-delay-${index}`]: true}">
|
||||
<CheckCard :key="index"
|
||||
class="photo-item"
|
||||
margin="0"
|
||||
@@ -51,6 +59,9 @@
|
||||
<script setup lang="ts">
|
||||
import empty from "@/assets/svgs/empty.svg";
|
||||
import useStore from "@/store";
|
||||
import complete from '@/assets/svgs/complete.svg';
|
||||
import stop from '@/assets/svgs/stop.svg';
|
||||
import greyComplete from '@/assets/svgs/grey-complete.svg';
|
||||
|
||||
const props = defineProps({
|
||||
imageList: {
|
||||
@@ -58,8 +69,56 @@ const props = defineProps({
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const iconSize = ref(23);
|
||||
const imageStore = useStore().image;
|
||||
|
||||
|
||||
const toggleGroup = (group: any) => {
|
||||
const currentIds = group.list.map((item: any) => item.id);
|
||||
const allSelected = currentIds.every(id =>
|
||||
imageStore.selected.includes(id)
|
||||
);
|
||||
|
||||
// 创建新数组保证响应式更新
|
||||
imageStore.selected = allSelected
|
||||
? imageStore.selected.filter(id => !currentIds.includes(id))
|
||||
: [...new Set([...imageStore.selected, ...currentIds])];
|
||||
};
|
||||
|
||||
// 新增计算分组状态
|
||||
const groupState = computed(() => (group: any) => {
|
||||
const currentIds = group.list.map((item: any) => item.id);
|
||||
if (currentIds.length === 0) return 'empty';
|
||||
|
||||
const selectedCount = currentIds.filter(id =>
|
||||
imageStore.selected.includes(id)
|
||||
).length;
|
||||
|
||||
if (selectedCount === currentIds.length) return 'all';
|
||||
if (selectedCount > 0) return 'partial';
|
||||
return 'none';
|
||||
});
|
||||
|
||||
// 动态获取图标
|
||||
const getGroupIcon = (group: any) => {
|
||||
switch (groupState.value(group)) {
|
||||
case 'all':
|
||||
return complete;
|
||||
case 'partial':
|
||||
return stop;
|
||||
default:
|
||||
return greyComplete;
|
||||
}
|
||||
};
|
||||
|
||||
// 新增计算属性判断是否有选中
|
||||
const hasSelected = computed(() => (group: any) => {
|
||||
return group.list.some((item: any) =>
|
||||
imageStore.selected.includes(item.id)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -71,4 +130,161 @@ const imageStore = useStore().image;
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.animate {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
animation: fadeInRight 0.5s forwards;
|
||||
}
|
||||
|
||||
@for $i from 0 through 30 { // 假设最多有20张图片,你可以根据实际情况调整
|
||||
.animate-delay-#{$i} {
|
||||
animation-delay: #{$i * 0.1}s; // 每张图片延迟0.1秒显示
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.date-text {
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
|
||||
/* 新增固定位置规则 */
|
||||
.has-selected & {
|
||||
transform: translateX(28px) !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.group-container {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
|
||||
// 有选中时的常显逻辑
|
||||
&.has-selected {
|
||||
.custom-checkbox {
|
||||
opacity: 1 !important;
|
||||
transform: translateX(8px) scale(1) !important;
|
||||
}
|
||||
|
||||
// 保持日期位移效果
|
||||
&:hover .date-text {
|
||||
transform: translateX(28px);
|
||||
}
|
||||
}
|
||||
|
||||
// 无选中时的悬停逻辑
|
||||
&:not(.has-selected) {
|
||||
.custom-checkbox {
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0);
|
||||
|
||||
// 悬停显示
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: translateX(8px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 分组悬停时的日期位移
|
||||
&:hover {
|
||||
.date-text {
|
||||
transform: translateX(28px);
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
opacity: 1;
|
||||
transform: translateX(8px) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes checkScale {
|
||||
0% {
|
||||
transform: translateX(8px) scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(8px) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(8px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: translateX(8px) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(8px) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(8px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.date-header {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin: 8px 0;
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0);
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 分开定义过渡属性 */
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.2s ease 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
.date-checkbox:checked + .custom-checkbox::after {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
Reference in New Issue
Block a user