mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add pictures extension to editor and canva
This commit is contained in:
parent
643d4ae2e7
commit
5eb9101084
11 changed files with 243 additions and 8 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
35
front/components/Editor/Extensions/Image/ImageBlock.ts
Normal file
35
front/components/Editor/Extensions/Image/ImageBlock.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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``;
|
||||||
29
front/components/Editor/Extensions/Video/VideoBlock.ts
Normal file
29
front/components/Editor/Extensions/Video/VideoBlock.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
38
front/services/files/images.ts
Normal file
38
front/services/files/images.ts
Normal 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));
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue