feat: add pictures extension to editor and canva

This commit is contained in:
swve 2022-12-13 18:18:51 +01:00
parent 643d4ae2e7
commit 5eb9101084
11 changed files with 243 additions and 8 deletions

View file

@ -4,6 +4,7 @@ import StarterKit from "@tiptap/starter-kit";
// Custom Extensions // Custom Extensions
import InfoCallout from "../Editor/Extensions/Callout/Info/InfoCallout"; import InfoCallout from "../Editor/Extensions/Callout/Info/InfoCallout";
import WarningCallout from "../Editor/Extensions/Callout/Warning/WarningCallout"; import WarningCallout from "../Editor/Extensions/Callout/Warning/WarningCallout";
import ImageBlock from "../Editor/Extensions/Image/ImageBlock";
interface Editor { interface Editor {
content: string; content: string;
@ -24,6 +25,10 @@ function Canva(props: Editor) {
WarningCallout.configure({ WarningCallout.configure({
editable: isEditable, editable: isEditable,
}), }),
ImageBlock.configure({
editable: isEditable,
element: props.element,
}),
], ],
content: props.content, content: props.content,

View file

@ -15,6 +15,7 @@ import Avvvatars from "avvvatars-react";
// extensions // extensions
import InfoCallout from "./Extensions/Callout/Info/InfoCallout"; import InfoCallout from "./Extensions/Callout/Info/InfoCallout";
import WarningCallout from "./Extensions/Callout/Warning/WarningCallout"; import WarningCallout from "./Extensions/Callout/Warning/WarningCallout";
import ImageBlock from "./Extensions/Image/ImageBlock";
interface Editor { interface Editor {
content: string; content: string;
@ -30,6 +31,7 @@ function Editor(props: Editor) {
const editor: any = useEditor({ const editor: any = useEditor({
editable: true, editable: true,
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
// The Collaboration extension comes with its own history handling // The Collaboration extension comes with its own history handling
@ -41,6 +43,10 @@ function Editor(props: Editor) {
WarningCallout.configure({ WarningCallout.configure({
editable: true, editable: true,
}), }),
ImageBlock.configure({
editable: true,
element: props.element
}),
// Register the document with Tiptap // Register the document with Tiptap
// Collaboration.configure({ // Collaboration.configure({
// document: props.ydoc, // document: props.ydoc,
@ -244,7 +250,8 @@ const EditorContentWrapper = styled.div`
.ProseMirror { .ProseMirror {
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-bottom: 6px; padding-bottom: 20px;
padding-top: 1px; padding-top: 1px;
&:focus { &:focus {
outline: none !important; outline: none !important;

View file

@ -7,7 +7,9 @@ export default Node.create({
name: "calloutInfo", name: "calloutInfo",
group: "block", group: "block",
draggable: true, draggable: true,
content: "inline*", content: "text*",
// TODO : multi line support
parseHTML() { parseHTML() {
return [ return [

View file

@ -7,7 +7,11 @@ export default Node.create({
name: "calloutWarning", name: "calloutWarning",
group: "block", group: "block",
draggable: true, draggable: true,
content: "inline*", content: "text*",
marks: "",
defining: true,
// TODO : multi line support
parseHTML() { parseHTML() {
return [ return [

View file

@ -0,0 +1,35 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import ImageBlockComponent from "./ImageBlockComponent";
export default Node.create({
name: "blockImage",
group: "block",
atom: true,
addAttributes() {
return {
fileObject: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "block-image",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["block-image", mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(ImageBlockComponent);
},
});

View file

@ -0,0 +1,89 @@
import { NodeViewWrapper } from "@tiptap/react";
import React from "react";
import styled from "styled-components";
import { AlertCircle, AlertTriangle, Image, ImagePlus, Info } from "lucide-react";
import { getImageFile, uploadNewImageFile } from "../../../../services/files/images";
import { getBackendUrl } from "../../../../services/config";
function ImageBlockComponent(props: any) {
const [image, setImage] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const [fileObject, setfileObject] = React.useState(props.node.attrs.fileObject);
const handleImageChange = (event: React.ChangeEvent<any>) => {
setImage(event.target.files[0]);
};
const handleSubmit = async (e: any) => {
e.preventDefault();
setIsLoading(true);
let object = await uploadNewImageFile(image, props.extension.options.element.element_id);
setIsLoading(false);
setfileObject(object);
props.updateAttributes({
fileObject: object,
});
};
return (
<NodeViewWrapper className="block-image">
{!fileObject && (
<BlockImageWrapper contentEditable={props.extension.options.editable}>
<div>
<Image color="#e1e0e0" size={50} />
<br />
</div>
<input onChange={handleImageChange} type="file" name="" id="" />
<br />
<button onClick={handleSubmit}>Submit</button>
</BlockImageWrapper>
)}
{fileObject && (
<BlockImage>
<img
src={`${getBackendUrl()}content/uploads/files/pictures/${props.extension.options.element.element_id}/${fileObject.file_id}.${
fileObject.file_format
}`}
alt=""
/>
</BlockImage>
)}
{isLoading && (
<div>
<AlertTriangle color="#e1e0e0" size={50} />
</div>
)}
</NodeViewWrapper>
);
}
export default ImageBlockComponent;
const BlockImageWrapper = styled.div`
display: flex;
flex-direction: column;
background: #f9f9f9;
border-radius: 3px;
padding: 30px;
min-height: 74px;
border: ${(props) => (props.contentEditable ? "2px dashed #713f1117" : "none")};
// center
align-items: center;
justify-content: center;
text-align: center;
font-size: 14px;
`;
const BlockImage = styled.div`
display: flex;
flex-direction: column;
img {
width: 100%;
border-radius: 6px;
height: 300px;
// cover
object-fit: cover;
}
`;
const ImageNotFound = styled.div``;

View file

@ -0,0 +1,29 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import VideoBlockComponent from "./VideoBlockComponent";
export default Node.create({
name: "calloutWarning",
group: "block",
draggable: true,
content: "inline*",
// TODO : multi line support
parseHTML() {
return [
{
tag: "callout-warning",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["callout-info", mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(VideoBlockComponent);
},
});

View file

@ -1,6 +1,6 @@
import styled from "styled-components"; import styled from "styled-components";
import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons"; import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon } from "@radix-ui/react-icons";
import { AlertCircle, AlertTriangle, Info } from "lucide-react"; import { AlertCircle, AlertTriangle, ImagePlus, Info } from "lucide-react";
export const ToolbarButtons = ({ editor }: any) => { export const ToolbarButtons = ({ editor }: any) => {
if (!editor) { if (!editor) {
@ -24,7 +24,15 @@ export const ToolbarButtons = ({ editor }: any) => {
<ToolBtn onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}> <ToolBtn onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}>
<StrikethroughIcon /> <StrikethroughIcon />
</ToolBtn> </ToolBtn>
<ToolSelect onChange={(e) => editor.chain().focus().toggleHeading({ level: parseInt(e.target.value) }).run() }> <ToolSelect
onChange={(e) =>
editor
.chain()
.focus()
.toggleHeading({ level: parseInt(e.target.value) })
.run()
}
>
<option value="1">Heading 1</option> <option value="1">Heading 1</option>
<option value="2">Heading 2</option> <option value="2">Heading 2</option>
<option value="3">Heading 3</option> <option value="3">Heading 3</option>
@ -33,12 +41,25 @@ export const ToolbarButtons = ({ editor }: any) => {
<option value="6">Heading 6</option> <option value="6">Heading 6</option>
</ToolSelect> </ToolSelect>
{/* TODO: fix this : toggling only works one-way */} {/* TODO: fix this : toggling only works one-way */}
<ToolBtn onClick={() => editor.chain().focus().toggleNode('calloutWarning').run()} > <ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutWarning").run()}>
<AlertTriangle size={15} /> <AlertTriangle size={15} />
</ToolBtn> </ToolBtn>
<ToolBtn onClick={() => editor.chain().focus().toggleNode('calloutInfo').run()} > <ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutInfo").run()}>
<AlertCircle size={15} /> <AlertCircle size={15} />
</ToolBtn> </ToolBtn>
<ToolBtn
onClick={() =>
editor
.chain()
.focus()
.insertContent({
type: "blockImage",
})
.run()
}
>
<ImagePlus size={15} />
</ToolBtn>
</ToolButtonsWrapper> </ToolButtonsWrapper>
); );
}; };
@ -60,7 +81,7 @@ const ToolBtn = styled.div`
margin-right: 5px; margin-right: 5px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
svg{ svg {
padding: 1px; padding: 1px;
} }

View file

@ -0,0 +1,38 @@
import { getAPIUrl } from "../config";
export async function uploadNewImageFile(file: any, element_id: string) {
const HeadersConfig = new Headers();
// Send file thumbnail as form data
const formData = new FormData();
formData.append("file_object", file);
formData.append("element_id", element_id);
const requestOptions: any = {
method: "POST",
headers: HeadersConfig,
redirect: "follow",
credentials: "include",
body: formData,
};
return fetch(`${getAPIUrl()}files/picture`, requestOptions)
.then((result) => result.json())
.catch((error) => console.log("error", error));
}
export async function getImageFile(file_id: string) {
const HeadersConfig = new Headers({ "Content-Type": "application/json" });
const requestOptions: any = {
method: "GET",
headers: HeadersConfig,
redirect: "follow",
credentials: "include",
};
// todo : add course id to url
return fetch(`${getAPIUrl()}files/picture?file_id=${file_id}`, requestOptions)
.then((result) => result.json())
.catch((error) => console.log("error", error));
}

View file

@ -3,6 +3,7 @@ from pydantic import BaseModel
from src.services.database import check_database, learnhouseDB, learnhouseDB from src.services.database import check_database, learnhouseDB, learnhouseDB
from fastapi import HTTPException, status, UploadFile from fastapi import HTTPException, status, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
import os
from src.services.users import PublicUser from src.services.users import PublicUser
@ -54,6 +55,10 @@ async def create_picture_file(picture_file: UploadFile, element_id: str):
element_id=element_id element_id=element_id
) )
# create folder for element
if not os.path.exists(f"content/uploads/files/pictures/{element_id}"):
os.mkdir(f"content/uploads/files/pictures/{element_id}")
# upload file to server # upload file to server
with open(f"content/uploads/files/pictures/{element_id}/{file_id}.{file_format}", 'wb') as f: with open(f"content/uploads/files/pictures/{element_id}/{file_id}.{file_format}", 'wb') as f:
f.write(file) f.write(file)