mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add ExternalObjects (Embeds) to editor
This commit is contained in:
parent
b1d05168b6
commit
d8be321021
7 changed files with 279 additions and 2 deletions
|
|
@ -24,6 +24,7 @@ import java from 'highlight.js/lib/languages/java'
|
||||||
import { NoTextInput } from '@components/Objects/Editor/Extensions/NoTextInput/NoTextInput'
|
import { NoTextInput } from '@components/Objects/Editor/Extensions/NoTextInput/NoTextInput'
|
||||||
import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext'
|
import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext'
|
||||||
import AICanvaToolkit from './AI/AICanvaToolkit'
|
import AICanvaToolkit from './AI/AICanvaToolkit'
|
||||||
|
import EmbedObjects from '@components/Objects/Editor/Extensions/EmbedObjects/EmbedObjects'
|
||||||
|
|
||||||
interface Editor {
|
interface Editor {
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -85,6 +86,10 @@ function Canva(props: Editor) {
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
}),
|
}),
|
||||||
|
EmbedObjects.configure({
|
||||||
|
editable: isEditable,
|
||||||
|
activity: props.activity,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
content: props.content,
|
content: props.content,
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import Collaboration from '@tiptap/extension-collaboration'
|
||||||
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
|
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
|
||||||
import ActiveAvatars from './ActiveAvatars'
|
import ActiveAvatars from './ActiveAvatars'
|
||||||
import { getUriWithOrg } from '@services/config/config'
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
|
import EmbedObjects from './Extensions/EmbedObjects/EmbedObjects'
|
||||||
|
|
||||||
interface Editor {
|
interface Editor {
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -133,6 +134,10 @@ function Editor(props: Editor) {
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
}),
|
}),
|
||||||
|
EmbedObjects.configure({
|
||||||
|
editable: true,
|
||||||
|
activity: props.activity,
|
||||||
|
}),
|
||||||
|
|
||||||
// Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true
|
// Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true
|
||||||
...(props.isCollabEnabledOnThisOrg ? [
|
...(props.isCollabEnabledOnThisOrg ? [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { mergeAttributes, Node } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import EmbedObjectsComponent from './EmbedObjectsComponent'
|
||||||
|
|
||||||
|
|
||||||
|
export default Node.create({
|
||||||
|
name: 'blockEmbed',
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
embedUrl: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
embedCode: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
embedType: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
embedHeight: {
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
embedWidth: {
|
||||||
|
default: '100%',
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
default: 'left',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'block-embed',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['block-embed', mergeAttributes(HTMLAttributes), 0]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(EmbedObjectsComponent)
|
||||||
|
},
|
||||||
|
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
|
||||||
|
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||||
|
import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy } from '@icons-pack/react-simple-icons'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
function EmbedObjectsComponent(props: any) {
|
||||||
|
const [embedType, setEmbedType] = useState<'url' | 'code'>(props.node.attrs.embedType || 'url')
|
||||||
|
const [embedUrl, setEmbedUrl] = useState(props.node.attrs.embedUrl || '')
|
||||||
|
const [embedCode, setEmbedCode] = useState(props.node.attrs.embedCode || '')
|
||||||
|
const [embedHeight, setEmbedHeight] = useState(props.node.attrs.embedHeight || 300)
|
||||||
|
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
|
||||||
|
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
|
||||||
|
|
||||||
|
const resizeRef = useRef<HTMLDivElement>(null)
|
||||||
|
const editorState = useEditorProvider() as any
|
||||||
|
const isEditable = editorState.isEditable
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supportedProducts = [
|
||||||
|
{ name: 'GitHub', icon: SiGithub, color: '#181717', guide: 'https://emgithub.com/' },
|
||||||
|
{ name: 'Replit', icon: SiReplit, color: '#F26207', guide: 'https://docs.replit.com/hosting/embedding-repls' },
|
||||||
|
{ name: 'Spotify', icon: SiSpotify, color: '#1DB954', guide: 'https://developer.spotify.com/documentation/embeds' },
|
||||||
|
{ name: 'Loom', icon: SiLoom, color: '#625DF5', guide: 'https://support.loom.com/hc/en-us/articles/360002208317-How-to-embed-your-video-into-a-webpage' },
|
||||||
|
{ name: 'GMaps', icon: SiGooglemaps, color: '#4285F4', guide: 'https://developers.google.com/maps/documentation/embed/get-started' },
|
||||||
|
{ name: 'CodePen', icon: SiCodepen, color: '#000000', guide: 'https://blog.codepen.io/documentation/embedded-pens/' },
|
||||||
|
{ name: 'Canva', icon: SiCanva, color: '#00C4CC', guide: 'https://www.canva.com/help/article/embed-designs' },
|
||||||
|
{ name: 'Notion', icon: SiNotion, color: '#878787', guide: 'https://www.notion.so/help/embed-and-connect-other-apps#7a70ac4b5c5f4ec889e69d262e0de9e7' },
|
||||||
|
{ name: 'G Docs', icon: SiGoogledocs, color: '#4285F4', guide: 'https://support.google.com/docs/answer/183965?hl=en&co=GENIE.Platform%3DDesktop' },
|
||||||
|
{ name: 'X', icon: SiX, color: '#000000', guide: 'https://help.twitter.com/en/using-twitter/how-to-embed-a-tweet' },
|
||||||
|
{ name: 'Figma', icon: SiFigma, color: '#F24E1E', guide: 'https://help.figma.com/hc/en-us/articles/360041057214-Embed-files-and-prototypes' },
|
||||||
|
{ name: 'Giphy', icon: SiGiphy, color: '#FF6666', guide: 'https://developers.giphy.com/docs/embed/' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleEmbedTypeChange = (type: 'url' | 'code') => {
|
||||||
|
setEmbedType(type)
|
||||||
|
props.updateAttributes({ embedType: type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newUrl = event.target.value;
|
||||||
|
setEmbedUrl(newUrl);
|
||||||
|
props.updateAttributes({
|
||||||
|
embedUrl: newUrl,
|
||||||
|
embedType: 'url',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCodeChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newCode = event.target.value;
|
||||||
|
setEmbedCode(newCode);
|
||||||
|
props.updateAttributes({
|
||||||
|
embedCode: newCode,
|
||||||
|
embedType: 'code',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeStart = (event: React.MouseEvent<HTMLDivElement>, direction: 'horizontal' | 'vertical') => {
|
||||||
|
event.preventDefault()
|
||||||
|
const startX = event.clientX
|
||||||
|
const startY = event.clientY
|
||||||
|
const startWidth = resizeRef.current?.offsetWidth || 0
|
||||||
|
const startHeight = resizeRef.current?.offsetHeight || 0
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (resizeRef.current) {
|
||||||
|
if (direction === 'horizontal') {
|
||||||
|
const newWidth = startWidth + e.clientX - startX
|
||||||
|
const parentWidth = resizeRef.current.parentElement?.offsetWidth || 1
|
||||||
|
const widthPercentage = Math.min(100, Math.max(10, (newWidth / parentWidth) * 100))
|
||||||
|
const newWidthValue = `${widthPercentage}%`
|
||||||
|
setEmbedWidth(newWidthValue)
|
||||||
|
props.updateAttributes({ embedWidth: newWidthValue })
|
||||||
|
} else {
|
||||||
|
const newHeight = Math.max(100, startHeight + e.clientY - startY)
|
||||||
|
setEmbedHeight(newHeight)
|
||||||
|
props.updateAttributes({ embedHeight: newHeight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCenterBlock = () => {
|
||||||
|
const newAlignment = alignment === 'center' ? 'left' : 'center'
|
||||||
|
setAlignment(newAlignment)
|
||||||
|
props.updateAttributes({ alignment: newAlignment })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProductClick = (guide: string) => {
|
||||||
|
window.open(guide, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="embed-block">
|
||||||
|
<div
|
||||||
|
ref={resizeRef}
|
||||||
|
className={`relative bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center ${alignment === 'center' ? 'mx-auto' : ''}`}
|
||||||
|
style={{ height: `${embedHeight}px`, width: embedWidth, minWidth: '400px' }}
|
||||||
|
>
|
||||||
|
{embedType === 'url' && embedUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
className="w-full h-full"
|
||||||
|
frameBorder="0"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : embedType === 'code' && embedCode ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: embedCode }} className="w-full h-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center p-6">
|
||||||
|
<p className="text-gray-500 mb-4 font-medium tracking-tighter text-lg">Add an embed from :</p>
|
||||||
|
<div className="flex flex-wrap gap-5 justify-center">
|
||||||
|
{supportedProducts.map((product) => (
|
||||||
|
<button
|
||||||
|
key={product.name}
|
||||||
|
className="flex flex-col items-center group transition-transform hover:scale-110"
|
||||||
|
onClick={() => handleProductClick(product.guide)}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow" style={{ backgroundColor: product.color }}>
|
||||||
|
<product.icon size={24} color="#FFFFFF" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs mt-2 text-gray-700 group-hover:text-gray-900 font-medium">{product.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 left-2 p-1 bg-white bg-opacity-70 rounded-md">
|
||||||
|
<Cuboid size={16} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
{isEditable && (
|
||||||
|
<>
|
||||||
|
<div className="absolute bottom-2 left-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEmbedTypeChange('url')}
|
||||||
|
className={`p-2 rounded-md transition-colors ${embedType === 'url' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<LinkIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEmbedTypeChange('code')}
|
||||||
|
className={`p-2 rounded-md transition-colors ${embedType === 'code' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<Code size={16} />
|
||||||
|
</button>
|
||||||
|
{embedType === 'url' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={embedUrl}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
className="p-2 bg-white bg-opacity-70 rounded-md w-64"
|
||||||
|
placeholder="Enter embed URL"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={embedCode}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
className="p-2 bg-white bg-opacity-70 rounded-md w-64 h-20"
|
||||||
|
placeholder="Enter embed code"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCenterBlock}
|
||||||
|
className="absolute bottom-2 right-2 p-2 bg-white bg-opacity-70 rounded-md hover:bg-opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<AlignCenter size={16} className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-4 cursor-ew-resize flex items-center justify-center bg-white bg-opacity-70 hover:bg-opacity-100 transition-opacity"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, 'horizontal')}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 bottom-0 h-4 cursor-ns-resize flex items-center justify-center bg-white bg-opacity-70 hover:bg-opacity-100 transition-opacity"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, 'vertical')}
|
||||||
|
>
|
||||||
|
<GripHorizontal size={16} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmbedObjectsComponent
|
||||||
|
|
@ -13,12 +13,13 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BadgeHelp,
|
BadgeHelp,
|
||||||
Code,
|
Code,
|
||||||
|
Cuboid,
|
||||||
FileText,
|
FileText,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Sigma,
|
Sigma,
|
||||||
Video,
|
Video,
|
||||||
Youtube,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { SiYoutube } from '@icons-pack/react-simple-icons'
|
||||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||||
|
|
||||||
export const ToolbarButtons = ({ editor, props }: any) => {
|
export const ToolbarButtons = ({ editor, props }: any) => {
|
||||||
|
|
@ -139,7 +140,7 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
<ToolTip content={'YouTube video'}>
|
<ToolTip content={'YouTube video'}>
|
||||||
<ToolBtn onClick={() => addYoutubeVideo()}>
|
<ToolBtn onClick={() => addYoutubeVideo()}>
|
||||||
<Youtube size={15} />
|
<SiYoutube size={15} />
|
||||||
</ToolBtn>
|
</ToolBtn>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
<ToolTip content={'Math Equation (LaTeX)'}>
|
<ToolTip content={'Math Equation (LaTeX)'}>
|
||||||
|
|
@ -195,6 +196,13 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
||||||
<Code size={15} />
|
<Code size={15} />
|
||||||
</ToolBtn>
|
</ToolBtn>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
<ToolTip content={'External Object (Embed)'}>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().insertContent({ type: 'blockEmbed' }).run()}
|
||||||
|
>
|
||||||
|
<Cuboid size={15} />
|
||||||
|
</ToolBtn>
|
||||||
|
</ToolTip>
|
||||||
</ToolButtonsWrapper>
|
</ToolButtonsWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/provider": "^2.13.5",
|
"@hocuspocus/provider": "^2.13.5",
|
||||||
|
"@icons-pack/react-simple-icons": "^10.0.0",
|
||||||
"@radix-ui/colors": "^0.1.9",
|
"@radix-ui/colors": "^0.1.9",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
|
|
|
||||||
12
apps/web/pnpm-lock.yaml
generated
12
apps/web/pnpm-lock.yaml
generated
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@hocuspocus/provider':
|
'@hocuspocus/provider':
|
||||||
specifier: ^2.13.5
|
specifier: ^2.13.5
|
||||||
version: 2.13.5(y-protocols@1.0.6(yjs@13.6.18))(yjs@13.6.18)
|
version: 2.13.5(y-protocols@1.0.6(yjs@13.6.18))(yjs@13.6.18)
|
||||||
|
'@icons-pack/react-simple-icons':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(react@18.3.1)
|
||||||
'@radix-ui/colors':
|
'@radix-ui/colors':
|
||||||
specifier: ^0.1.9
|
specifier: ^0.1.9
|
||||||
version: 0.1.9
|
version: 0.1.9
|
||||||
|
|
@ -384,6 +387,11 @@ packages:
|
||||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||||
deprecated: Use @eslint/object-schema instead
|
deprecated: Use @eslint/object-schema instead
|
||||||
|
|
||||||
|
'@icons-pack/react-simple-icons@10.0.0':
|
||||||
|
resolution: {integrity: sha512-oU0PVDx9sbNQjRxJN555dsHbRApYN+aBq/O9+wo3JgNkEfvBMgAEtsSGtXWWXQsLAxJcYiFOCzBWege/Xj/JFQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.13 || ^17 || ^18
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.33.5':
|
'@img/sharp-darwin-arm64@0.33.5':
|
||||||
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
@ -4137,6 +4145,10 @@ snapshots:
|
||||||
|
|
||||||
'@humanwhocodes/object-schema@2.0.3': {}
|
'@humanwhocodes/object-schema@2.0.3': {}
|
||||||
|
|
||||||
|
'@icons-pack/react-simple-icons@10.0.0(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.33.5':
|
'@img/sharp-darwin-arm64@0.33.5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue