mirror of https://github.com/usememos/memos
chore: update timeline page
parent
b14334220f
commit
9361613f23
@ -0,0 +1,17 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package memos.api.v2;
|
||||
|
||||
option go_package = "gen/api/v2";
|
||||
|
||||
message MemoRelation {
|
||||
int32 memo_id = 1;
|
||||
int32 related_memo_id = 2;
|
||||
|
||||
enum Type {
|
||||
TYPE_UNSPECIFIED = 0;
|
||||
REFERENCE = 1;
|
||||
COMMENT = 2;
|
||||
}
|
||||
Type type = 3;
|
||||
}
|
@ -0,0 +1,232 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.31.0
|
||||
// protoc (unknown)
|
||||
// source: api/v2/memo_relation_service.proto
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type MemoRelation_Type int32
|
||||
|
||||
const (
|
||||
MemoRelation_TYPE_UNSPECIFIED MemoRelation_Type = 0
|
||||
MemoRelation_REFERENCE MemoRelation_Type = 1
|
||||
MemoRelation_COMMENT MemoRelation_Type = 2
|
||||
)
|
||||
|
||||
// Enum value maps for MemoRelation_Type.
|
||||
var (
|
||||
MemoRelation_Type_name = map[int32]string{
|
||||
0: "TYPE_UNSPECIFIED",
|
||||
1: "REFERENCE",
|
||||
2: "COMMENT",
|
||||
}
|
||||
MemoRelation_Type_value = map[string]int32{
|
||||
"TYPE_UNSPECIFIED": 0,
|
||||
"REFERENCE": 1,
|
||||
"COMMENT": 2,
|
||||
}
|
||||
)
|
||||
|
||||
func (x MemoRelation_Type) Enum() *MemoRelation_Type {
|
||||
p := new(MemoRelation_Type)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x MemoRelation_Type) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (MemoRelation_Type) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_api_v2_memo_relation_service_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (MemoRelation_Type) Type() protoreflect.EnumType {
|
||||
return &file_api_v2_memo_relation_service_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x MemoRelation_Type) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MemoRelation_Type.Descriptor instead.
|
||||
func (MemoRelation_Type) EnumDescriptor() ([]byte, []int) {
|
||||
return file_api_v2_memo_relation_service_proto_rawDescGZIP(), []int{0, 0}
|
||||
}
|
||||
|
||||
type MemoRelation struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
|
||||
RelatedMemoId int32 `protobuf:"varint,2,opt,name=related_memo_id,json=relatedMemoId,proto3" json:"related_memo_id,omitempty"`
|
||||
Type MemoRelation_Type `protobuf:"varint,3,opt,name=type,proto3,enum=memos.api.v2.MemoRelation_Type" json:"type,omitempty"`
|
||||
}
|
||||
|
||||
func (x *MemoRelation) Reset() {
|
||||
*x = MemoRelation{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_v2_memo_relation_service_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *MemoRelation) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MemoRelation) ProtoMessage() {}
|
||||
|
||||
func (x *MemoRelation) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v2_memo_relation_service_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MemoRelation.ProtoReflect.Descriptor instead.
|
||||
func (*MemoRelation) Descriptor() ([]byte, []int) {
|
||||
return file_api_v2_memo_relation_service_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *MemoRelation) GetMemoId() int32 {
|
||||
if x != nil {
|
||||
return x.MemoId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *MemoRelation) GetRelatedMemoId() int32 {
|
||||
if x != nil {
|
||||
return x.RelatedMemoId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *MemoRelation) GetType() MemoRelation_Type {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return MemoRelation_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
var File_api_v2_memo_relation_service_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_api_v2_memo_relation_service_proto_rawDesc = []byte{
|
||||
0x0a, 0x22, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x72, 0x65,
|
||||
0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x6d, 0x65, 0x6d, 0x6f, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x76, 0x32, 0x22, 0xbe, 0x01, 0x0a, 0x0c, 0x4d, 0x65, 0x6d, 0x6f, 0x52, 0x65, 0x6c, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f,
|
||||
0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4d, 0x65,
|
||||
0x6d, 0x6f, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x65, 0x6d, 0x6f, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76,
|
||||
0x32, 0x2e, 0x4d, 0x65, 0x6d, 0x6f, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x54,
|
||||
0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x38, 0x0a, 0x04, 0x54, 0x79, 0x70,
|
||||
0x65, 0x12, 0x14, 0x0a, 0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43,
|
||||
0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x52, 0x45, 0x46, 0x45, 0x52,
|
||||
0x45, 0x4e, 0x43, 0x45, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x4d, 0x4d, 0x45, 0x4e,
|
||||
0x54, 0x10, 0x02, 0x42, 0xb0, 0x01, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x2e, 0x6d, 0x65, 0x6d, 0x6f,
|
||||
0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32, 0x42, 0x18, 0x4d, 0x65, 0x6d, 0x6f, 0x52, 0x65,
|
||||
0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x50, 0x01, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
|
||||
0x2f, 0x75, 0x73, 0x65, 0x6d, 0x65, 0x6d, 0x6f, 0x73, 0x2f, 0x6d, 0x65, 0x6d, 0x6f, 0x73, 0x2f,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32,
|
||||
0x3b, 0x61, 0x70, 0x69, 0x76, 0x32, 0xa2, 0x02, 0x03, 0x4d, 0x41, 0x58, 0xaa, 0x02, 0x0c, 0x4d,
|
||||
0x65, 0x6d, 0x6f, 0x73, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x56, 0x32, 0xca, 0x02, 0x0c, 0x4d, 0x65,
|
||||
0x6d, 0x6f, 0x73, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x56, 0x32, 0xe2, 0x02, 0x18, 0x4d, 0x65, 0x6d,
|
||||
0x6f, 0x73, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x56, 0x32, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x4d, 0x65, 0x6d, 0x6f, 0x73, 0x3a, 0x3a, 0x41,
|
||||
0x70, 0x69, 0x3a, 0x3a, 0x56, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_api_v2_memo_relation_service_proto_rawDescOnce sync.Once
|
||||
file_api_v2_memo_relation_service_proto_rawDescData = file_api_v2_memo_relation_service_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_api_v2_memo_relation_service_proto_rawDescGZIP() []byte {
|
||||
file_api_v2_memo_relation_service_proto_rawDescOnce.Do(func() {
|
||||
file_api_v2_memo_relation_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_v2_memo_relation_service_proto_rawDescData)
|
||||
})
|
||||
return file_api_v2_memo_relation_service_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_api_v2_memo_relation_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_api_v2_memo_relation_service_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
|
||||
var file_api_v2_memo_relation_service_proto_goTypes = []interface{}{
|
||||
(MemoRelation_Type)(0), // 0: memos.api.v2.MemoRelation.Type
|
||||
(*MemoRelation)(nil), // 1: memos.api.v2.MemoRelation
|
||||
}
|
||||
var file_api_v2_memo_relation_service_proto_depIdxs = []int32{
|
||||
0, // 0: memos.api.v2.MemoRelation.type:type_name -> memos.api.v2.MemoRelation.Type
|
||||
1, // [1:1] is the sub-list for method output_type
|
||||
1, // [1:1] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_v2_memo_relation_service_proto_init() }
|
||||
func file_api_v2_memo_relation_service_proto_init() {
|
||||
if File_api_v2_memo_relation_service_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_api_v2_memo_relation_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*MemoRelation); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_api_v2_memo_relation_service_proto_rawDesc,
|
||||
NumEnums: 1,
|
||||
NumMessages: 1,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_api_v2_memo_relation_service_proto_goTypes,
|
||||
DependencyIndexes: file_api_v2_memo_relation_service_proto_depIdxs,
|
||||
EnumInfos: file_api_v2_memo_relation_service_proto_enumTypes,
|
||||
MessageInfos: file_api_v2_memo_relation_service_proto_msgTypes,
|
||||
}.Build()
|
||||
File_api_v2_memo_relation_service_proto = out.File
|
||||
file_api_v2_memo_relation_service_proto_rawDesc = nil
|
||||
file_api_v2_memo_relation_service_proto_goTypes = nil
|
||||
file_api_v2_memo_relation_service_proto_depIdxs = nil
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,52 @@
|
||||
import { IconButton } from "@mui/joy";
|
||||
import { useEffect } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
import OverflowTip from "@/components/kit/OverflowTip";
|
||||
import { useTagStore } from "@/store/module";
|
||||
|
||||
interface Props {
|
||||
onTagSelectorClick: (tag: string) => void;
|
||||
}
|
||||
|
||||
const TagSelector = (props: Props) => {
|
||||
const { onTagSelectorClick } = props;
|
||||
const tagStore = useTagStore();
|
||||
const tags = tagStore.state.tags;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await tagStore.fetchTags();
|
||||
} catch (error) {
|
||||
// do nothing.
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IconButton className="relative group flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow">
|
||||
<Icon.Hash className="w-5 h-5 mx-auto" />
|
||||
<div className="hidden flex-row justify-start items-start flex-wrap absolute top-8 left-0 mt-1 p-1 z-1 rounded w-52 h-auto max-h-48 overflow-y-auto font-mono shadow bg-zinc-200 dark:bg-zinc-600 group-hover:flex">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
<div
|
||||
className="w-auto max-w-full text-black dark:text-gray-300 cursor-pointer rounded text-sm leading-6 px-2 hover:bg-zinc-300 dark:hover:bg-zinc-700 shrink-0"
|
||||
onClick={() => onTagSelectorClick(tag)}
|
||||
key={tag}
|
||||
>
|
||||
<OverflowTip>#{tag}</OverflowTip>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="italic text-sm ml-2" onClick={(e) => e.stopPropagation()}>
|
||||
No tags found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSelector;
|
@ -0,0 +1,136 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import OverflowTip from "@/components/kit/OverflowTip";
|
||||
import { useTagStore } from "@/store/module";
|
||||
import { EditorRefActions } from ".";
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<HTMLTextAreaElement>;
|
||||
editorActions: React.ForwardedRef<EditorRefActions>;
|
||||
};
|
||||
|
||||
type Position = { left: number; top: number; height: number };
|
||||
|
||||
const TagSuggestions = ({ editorRef, editorActions }: Props) => {
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const hide = () => setPosition(null);
|
||||
|
||||
const { state } = useTagStore();
|
||||
const tagsRef = useRef(state.tags);
|
||||
tagsRef.current = state.tags;
|
||||
|
||||
const [selected, select] = useState(0);
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
|
||||
const getCurrentWord = (): [word: string, startIndex: number] => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return ["", 0];
|
||||
const cursorPos = editor.selectionEnd;
|
||||
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
|
||||
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
|
||||
return [before[0] + after[0], before.index ?? cursorPos];
|
||||
};
|
||||
|
||||
const suggestionsRef = useRef<string[]>([]);
|
||||
suggestionsRef.current = (() => {
|
||||
const input = getCurrentWord()[0].slice(1).toLowerCase();
|
||||
|
||||
const customMatches = (tag: string, input: string) => {
|
||||
const tagLowerCase = tag.toLowerCase();
|
||||
const inputLowerCase = input.toLowerCase();
|
||||
let inputIndex = 0;
|
||||
|
||||
for (let i = 0; i < tagLowerCase.length; i++) {
|
||||
if (tagLowerCase[i] === inputLowerCase[inputIndex]) {
|
||||
inputIndex++;
|
||||
if (inputIndex === inputLowerCase.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const matchedTags = tagsRef.current.filter((tag) => customMatches(tag, input));
|
||||
return matchedTags.slice(0, 5);
|
||||
})();
|
||||
|
||||
const isVisibleRef = useRef(false);
|
||||
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
|
||||
|
||||
const autocomplete = (tag: string) => {
|
||||
if (!editorActions || !("current" in editorActions) || !editorActions.current) return;
|
||||
const [word, index] = getCurrentWord();
|
||||
editorActions.current.removeText(index, word.length);
|
||||
editorActions.current.insertText(`#${tag}`);
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisibleRef.current) return;
|
||||
const suggestions = suggestionsRef.current;
|
||||
const selected = selectedRef.current;
|
||||
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) hide();
|
||||
if ("ArrowDown" === e.code) {
|
||||
select((selected + 1) % suggestions.length);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if ("ArrowUp" === e.code) {
|
||||
select((selected - 1 + suggestions.length) % suggestions.length);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (["Enter", "Tab"].includes(e.code)) {
|
||||
autocomplete(suggestions[selected]);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
if (!editorRef.current) return;
|
||||
select(0);
|
||||
const [word, index] = getCurrentWord();
|
||||
const isActive = word.startsWith("#") && !word.slice(1).includes("#");
|
||||
isActive ? setPosition(getCaretCoordinates(editorRef.current, index)) : hide();
|
||||
};
|
||||
|
||||
const listenersAreRegisteredRef = useRef(false);
|
||||
const registerListeners = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || listenersAreRegisteredRef.current) return;
|
||||
editor.addEventListener("click", hide);
|
||||
editor.addEventListener("blur", hide);
|
||||
editor.addEventListener("keydown", handleKeyDown);
|
||||
editor.addEventListener("input", handleInput);
|
||||
listenersAreRegisteredRef.current = true;
|
||||
};
|
||||
useEffect(registerListeners, [!!editorRef.current]);
|
||||
|
||||
if (!isVisibleRef.current || !position) return null;
|
||||
return (
|
||||
<div
|
||||
className="z-20 p-1 mt-1 -ml-2 absolute max-w-[12rem] gap-px rounded font-mono flex flex-col justify-start items-start overflow-auto shadow bg-zinc-200 dark:bg-zinc-600"
|
||||
style={{ left: position.left, top: position.top + position.height }}
|
||||
>
|
||||
{suggestionsRef.current.map((tag, i) => (
|
||||
<div
|
||||
key={tag}
|
||||
onMouseDown={() => autocomplete(tag)}
|
||||
className={classNames(
|
||||
"rounded p-1 px-2 w-full truncate text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700",
|
||||
i === selected ? "bg-zinc-300 dark:bg-zinc-700" : ""
|
||||
)}
|
||||
>
|
||||
<OverflowTip>#{tag}</OverflowTip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSuggestions;
|
@ -0,0 +1,162 @@
|
||||
import classNames from "classnames";
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
|
||||
export interface EditorRefActions {
|
||||
focus: FunctionType;
|
||||
scrollToCursor: FunctionType;
|
||||
insertText: (text: string, prefix?: string, suffix?: string) => void;
|
||||
removeText: (start: number, length: number) => void;
|
||||
setContent: (text: string) => void;
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
setCursorPosition: (startPos: number, endPos?: number) => void;
|
||||
getCursorLineNumber: () => number;
|
||||
getLine: (lineNumber: number) => string;
|
||||
setLine: (lineNumber: number, text: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
placeholder: string;
|
||||
tools?: ReactNode;
|
||||
onContentChange: (content: string) => void;
|
||||
onPaste: (event: React.ClipboardEvent) => void;
|
||||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback } = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
handleContentChangeCallback(initialContent);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [editorRef.current?.value]);
|
||||
|
||||
const updateEditorHeight = () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
scrollToCursor: () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.scrollTop = editorRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPosition = editorRef.current.selectionStart;
|
||||
const endPosition = editorRef.current.selectionEnd;
|
||||
const prevValue = editorRef.current.value;
|
||||
const value =
|
||||
prevValue.slice(0, cursorPosition) +
|
||||
prefix +
|
||||
(content || prevValue.slice(cursorPosition, endPosition)) +
|
||||
suffix +
|
||||
prevValue.slice(endPosition);
|
||||
|
||||
editorRef.current.value = value;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = endPosition + prefix.length + content.length;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevValue = editorRef.current.value;
|
||||
const value = prevValue.slice(0, start) + prevValue.slice(start + length);
|
||||
editorRef.current.value = value;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = start;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = text;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
},
|
||||
getContent: (): string => {
|
||||
return editorRef.current?.value ?? "";
|
||||
},
|
||||
getCursorPosition: (): number => {
|
||||
return editorRef.current?.selectionStart ?? 0;
|
||||
},
|
||||
getSelectedContent: () => {
|
||||
const start = editorRef.current?.selectionStart;
|
||||
const end = editorRef.current?.selectionEnd;
|
||||
return editorRef.current?.value.slice(start, end) ?? "";
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number);
|
||||
editorRef.current?.setSelectionRange(startPos, _endPos);
|
||||
},
|
||||
getCursorLineNumber: () => {
|
||||
const cursorPosition = editorRef.current?.selectionStart ?? 0;
|
||||
const lines = editorRef.current?.value.slice(0, cursorPosition).split("\n") ?? [];
|
||||
return lines.length - 1;
|
||||
},
|
||||
getLine: (lineNumber: number) => {
|
||||
return editorRef.current?.value.split("\n")[lineNumber] ?? "";
|
||||
},
|
||||
setLine: (lineNumber: number, text: string) => {
|
||||
const lines = editorRef.current?.value.split("\n") ?? [];
|
||||
lines[lineNumber] = text;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = lines.join("\n");
|
||||
editorRef.current.focus();
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
handleContentChangeCallback(editorRef.current?.value ?? "");
|
||||
updateEditorHeight();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames("flex flex-col justify-start items-start relative w-full h-auto bg-inherit dark:text-gray-200", className)}>
|
||||
<textarea
|
||||
className="w-full h-full max-h-[300px] my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break"
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onPaste={onPaste}
|
||||
onInput={handleEditorInput}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Editor;
|
@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalStore, useTagStore } from "@/store/module";
|
||||
import MemoEditor from ".";
|
||||
import { generateDialog } from "../Dialog";
|
||||
import Icon from "../Icon";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memoId?: MemoId;
|
||||
relationList?: MemoRelation[];
|
||||
}
|
||||
|
||||
const MemoEditorDialog: React.FC<Props> = ({ memoId, relationList, destroy }: Props) => {
|
||||
const globalStore = useGlobalStore();
|
||||
const tagStore = useTagStore();
|
||||
const { systemStatus } = globalStore.state;
|
||||
|
||||
useEffect(() => {
|
||||
tagStore.fetchTags();
|
||||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<img className="w-5 h-auto rounded-full shadow" src={systemStatus.customizedProfile.logoUrl} alt="" />
|
||||
<p className="ml-1 text-black opacity-80 dark:text-gray-200">{systemStatus.customizedProfile.name}</p>
|
||||
</div>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start max-w-full w-[36rem]">
|
||||
<MemoEditor
|
||||
className="border-none !p-0 -mb-2"
|
||||
cacheKey={`memo-editor-${memoId}`}
|
||||
memoId={memoId}
|
||||
relationList={relationList}
|
||||
onConfirm={handleCloseBtnClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showMemoEditorDialog(props: Pick<Props, "memoId" | "relationList"> = {}): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "memo-editor-dialog",
|
||||
dialogName: "memo-editor-dialog",
|
||||
containerClassName: "dark:!bg-zinc-700",
|
||||
},
|
||||
MemoEditorDialog,
|
||||
props
|
||||
);
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemoCacheStore } from "@/store/v1";
|
||||
import { MemoRelation, MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service";
|
||||
import Icon from "../Icon";
|
||||
|
||||
interface Props {
|
||||
relationList: MemoRelation[];
|
||||
setRelationList: (relationList: MemoRelation[]) => void;
|
||||
}
|
||||
|
||||
const RelationListView = (props: Props) => {
|
||||
const { relationList, setRelationList } = props;
|
||||
const memoCacheStore = useMemoCacheStore();
|
||||
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const requests = relationList
|
||||
.filter((relation) => relation.type === MemoRelation_Type.REFERENCE)
|
||||
.map(async (relation) => {
|
||||
return await memoCacheStore.getOrFetchMemoById(relation.relatedMemoId);
|
||||
});
|
||||
const list = await Promise.all(requests);
|
||||
setReferencingMemoList(list);
|
||||
})();
|
||||
}, [relationList]);
|
||||
|
||||
const handleDeleteRelation = async (memo: Memo) => {
|
||||
setRelationList(relationList.filter((relation) => relation.relatedMemoId !== memo.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{referencingMemoList.length > 0 && (
|
||||
<div className="w-full flex flex-row gap-2 mt-2 flex-wrap">
|
||||
{referencingMemoList.map((memo) => {
|
||||
return (
|
||||
<div
|
||||
key={memo.id}
|
||||
className="w-auto max-w-xs overflow-hidden flex flex-row justify-start items-center bg-gray-100 dark:bg-zinc-800 hover:opacity-80 rounded-md text-sm p-1 px-2 text-gray-500 cursor-pointer hover:line-through"
|
||||
onClick={() => handleDeleteRelation(memo)}
|
||||
>
|
||||
<Icon.Link className="w-4 h-auto shrink-0 opacity-80" />
|
||||
<span className="px-1 shrink-0 opacity-80">#{memo.id}</span>
|
||||
<span className="max-w-full text-ellipsis whitespace-nowrap overflow-hidden">{memo.content}</span>
|
||||
<Icon.X className="w-4 h-auto hover:opacity-80 shrink-0 ml-1" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelationListView;
|
@ -0,0 +1,42 @@
|
||||
import { Resource } from "@/types/proto/api/v2/resource_service";
|
||||
import Icon from "../Icon";
|
||||
import ResourceIcon from "../ResourceIcon";
|
||||
|
||||
interface Props {
|
||||
resourceList: Resource[];
|
||||
setResourceList: (resourceList: Resource[]) => void;
|
||||
}
|
||||
|
||||
const ResourceListView = (props: Props) => {
|
||||
const { resourceList, setResourceList } = props;
|
||||
|
||||
const handleDeleteResource = async (resourceId: ResourceId) => {
|
||||
setResourceList(resourceList.filter((resource) => resource.id !== resourceId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{resourceList.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start flex-wrap gap-2 mt-2">
|
||||
{resourceList.map((resource) => {
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="max-w-full flex flex-row justify-start items-center flex-nowrap gap-x-1 bg-gray-100 dark:bg-zinc-800 px-2 py-1 rounded text-gray-500"
|
||||
>
|
||||
<ResourceIcon resource={resource} className="!w-4 !h-4 !opacity-100" />
|
||||
<span className="text-sm max-w-[8rem] truncate">{resource.filename}</span>
|
||||
<Icon.X
|
||||
className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => handleDeleteResource(resource.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceListView;
|
@ -0,0 +1,454 @@
|
||||
import { Select, Option, Button, IconButton, Divider } from "@mui/joy";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { TAB_SPACE_WIDTH, UNKNOWN_ID } from "@/helpers/consts";
|
||||
import { useGlobalStore, useResourceStore } from "@/store/module";
|
||||
import { useMemoV1Store, useUserV1Store } from "@/store/v1";
|
||||
import { MemoRelation, MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service";
|
||||
import { Visibility } from "@/types/proto/api/v2/memo_service";
|
||||
import { Resource } from "@/types/proto/api/v2/resource_service";
|
||||
import { UserSetting } from "@/types/proto/api/v2/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
||||
import showCreateMemoRelationDialog from "../CreateMemoRelationDialog";
|
||||
import showCreateResourceDialog from "../CreateResourceDialog";
|
||||
import Icon from "../Icon";
|
||||
import VisibilityIconV1 from "../VisibilityIconV1";
|
||||
import TagSelector from "./ActionButton/TagSelector";
|
||||
import Editor, { EditorRefActions } from "./Editor";
|
||||
import RelationListView from "./RelationListView";
|
||||
import ResourceListView from "./ResourceListView";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
editorClassName?: string;
|
||||
cacheKey?: string;
|
||||
memoId?: number;
|
||||
relationList?: MemoRelation[];
|
||||
onConfirm?: (memoId: number) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
memoVisibility: Visibility;
|
||||
resourceList: Resource[];
|
||||
relationList: MemoRelation[];
|
||||
isUploadingResource: boolean;
|
||||
isRequesting: boolean;
|
||||
}
|
||||
|
||||
const MemoEditor = (props: Props) => {
|
||||
const { className, editorClassName, cacheKey, memoId, onConfirm } = props;
|
||||
const { i18n } = useTranslation();
|
||||
const t = useTranslate();
|
||||
const contentCacheKey = `memo-editor-${cacheKey}`;
|
||||
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
|
||||
const {
|
||||
state: { systemStatus },
|
||||
} = useGlobalStore();
|
||||
const userV1Store = useUserV1Store();
|
||||
const memoStore = useMemoV1Store();
|
||||
const resourceStore = useResourceStore();
|
||||
const [state, setState] = useState<State>({
|
||||
memoVisibility: Visibility.PRIVATE,
|
||||
resourceList: [],
|
||||
relationList: props.relationList ?? [],
|
||||
isUploadingResource: false,
|
||||
isRequesting: false,
|
||||
});
|
||||
const [hasContent, setHasContent] = useState<boolean>(false);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const userSetting = userV1Store.userSetting as UserSetting;
|
||||
const referenceRelations = memoId
|
||||
? state.relationList.filter(
|
||||
(relation) => relation.memoId === memoId && relation.relatedMemoId !== memoId && relation.type === MemoRelation_Type.REFERENCE
|
||||
)
|
||||
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.setContent(contentCache || "");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let visibility = userSetting.memoVisibility;
|
||||
if (systemStatus.disablePublicMemos && visibility === "PUBLIC") {
|
||||
visibility = "PRIVATE";
|
||||
}
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: convertVisibilityFromString(visibility),
|
||||
}));
|
||||
}, [userSetting.memoVisibility, systemStatus.disablePublicMemos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoId) {
|
||||
memoStore.getOrFetchMemoById(memoId ?? UNKNOWN_ID).then((memo) => {
|
||||
if (memo) {
|
||||
handleEditorFocus();
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: memo.visibility,
|
||||
}));
|
||||
if (!contentCache) {
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [memoId]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetaKey = event.ctrlKey || event.metaKey;
|
||||
if (isMetaKey) {
|
||||
if (event.key === "Enter") {
|
||||
handleSaveBtnClick();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const selectedContent = editorRef.current.getSelectedContent();
|
||||
editorRef.current.insertText(tabSpace);
|
||||
if (selectedContent) {
|
||||
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemoVisibilityChange = (visibility: Visibility) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
memoVisibility: visibility,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUploadFileBtnClick = () => {
|
||||
showCreateResourceDialog({
|
||||
onConfirm: (resourceList) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
resourceList: [...prevState.resourceList, ...resourceList],
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMemoRelationBtnClick = () => {
|
||||
showCreateMemoRelationDialog({
|
||||
onConfirm: (memoIdList) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
relationList: uniqBy(
|
||||
[
|
||||
...memoIdList.map((id) => ({ memoId: memoId || UNKNOWN_ID, relatedMemoId: id, type: MemoRelation_Type.REFERENCE })),
|
||||
...state.relationList,
|
||||
].filter((relation) => relation.relatedMemoId !== (memoId || UNKNOWN_ID)),
|
||||
"relatedMemoId"
|
||||
),
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetResourceList = (resourceList: Resource[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
resourceList,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSetRelationList = (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
relationList,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUploadResource = async (file: File) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingResource: true,
|
||||
};
|
||||
});
|
||||
|
||||
let resource = undefined;
|
||||
try {
|
||||
resource = await resourceStore.createResourceWithBlob(file);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(typeof error === "string" ? error : error.response.data.message);
|
||||
}
|
||||
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingResource: false,
|
||||
};
|
||||
});
|
||||
return resource;
|
||||
};
|
||||
|
||||
const uploadMultiFiles = async (files: FileList) => {
|
||||
const uploadedResourceList: Resource[] = [];
|
||||
for (const file of files) {
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
uploadedResourceList.push(resource);
|
||||
if (memoId) {
|
||||
await resourceStore.updateResource({
|
||||
resource: Resource.fromPartial({
|
||||
id: resource.id,
|
||||
memoId,
|
||||
}),
|
||||
updateMask: ["memo_id"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploadedResourceList.length > 0) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
resourceList: [...prevState.resourceList, ...uploadedResourceList],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
await uploadMultiFiles(event.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
await uploadMultiFiles(event.clipboardData.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setHasContent(content !== "");
|
||||
if (content !== "") {
|
||||
setContentCache(content);
|
||||
} else {
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (state.isRequesting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: true,
|
||||
};
|
||||
});
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
try {
|
||||
if (memoId && memoId !== UNKNOWN_ID) {
|
||||
const prevMemo = await memoStore.getOrFetchMemoById(memoId ?? UNKNOWN_ID);
|
||||
if (prevMemo) {
|
||||
const memo = await memoStore.updateMemo(
|
||||
{
|
||||
id: prevMemo.id,
|
||||
content,
|
||||
visibility: state.memoVisibility,
|
||||
},
|
||||
["content", "visibility"]
|
||||
);
|
||||
await memoServiceClient.setMemoResources({
|
||||
id: memo.id,
|
||||
resources: state.resourceList,
|
||||
});
|
||||
await memoServiceClient.setMemoRelations({
|
||||
id: memo.id,
|
||||
relations: state.relationList,
|
||||
});
|
||||
if (onConfirm) {
|
||||
onConfirm(memo.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const memo = await memoStore.createMemo({
|
||||
content,
|
||||
visibility: state.memoVisibility,
|
||||
});
|
||||
await memoServiceClient.setMemoResources({
|
||||
id: memo.id,
|
||||
resources: state.resourceList,
|
||||
});
|
||||
await memoServiceClient.setMemoRelations({
|
||||
id: memo.id,
|
||||
relations: state.relationList,
|
||||
});
|
||||
if (onConfirm) {
|
||||
onConfirm(memo.id);
|
||||
}
|
||||
}
|
||||
editorRef.current?.setContent("");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: false,
|
||||
};
|
||||
});
|
||||
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
resourceList: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCheckBoxBtnClick = () => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentPosition = editorRef.current?.getCursorPosition();
|
||||
const currentLineNumber = editorRef.current?.getCursorLineNumber();
|
||||
const currentLine = editorRef.current?.getLine(currentLineNumber);
|
||||
let newLine = "";
|
||||
let cursorChange = 0;
|
||||
if (/^- \[( |x|X)\] /.test(currentLine)) {
|
||||
newLine = currentLine.replace(/^- \[( |x|X)\] /, "");
|
||||
cursorChange = -6;
|
||||
} else if (/^\d+\. |- /.test(currentLine)) {
|
||||
const match = currentLine.match(/^\d+\. |- /) ?? [""];
|
||||
newLine = currentLine.replace(/^\d+\. |- /, "- [ ] ");
|
||||
cursorChange = -match[0].length + 6;
|
||||
} else {
|
||||
newLine = "- [ ] " + currentLine;
|
||||
cursorChange = 6;
|
||||
}
|
||||
editorRef.current?.setLine(currentLineNumber, newLine);
|
||||
editorRef.current.setCursorPosition(currentPosition + cursorChange);
|
||||
editorRef.current?.scrollToCursor();
|
||||
};
|
||||
|
||||
const handleCodeBlockBtnClick = () => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
if (prevValue === "" || prevValue.endsWith("\n")) {
|
||||
editorRef.current?.insertText("", "```\n", "\n```");
|
||||
} else {
|
||||
editorRef.current?.insertText("", "\n```\n", "\n```");
|
||||
}
|
||||
editorRef.current?.scrollToCursor();
|
||||
};
|
||||
|
||||
const handleTagSelectorClick = useCallback((tag: string) => {
|
||||
editorRef.current?.insertText(`#${tag} `);
|
||||
}, []);
|
||||
|
||||
const handleEditorFocus = () => {
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: editorClassName ?? "",
|
||||
initialContent: "",
|
||||
placeholder: t("editor.placeholder"),
|
||||
onContentChange: handleContentChange,
|
||||
onPaste: handlePasteEvent,
|
||||
}),
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const allowSave = (hasContent || state.resourceList.length > 0) && !state.isUploadingResource && !state.isRequesting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
className ?? ""
|
||||
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-700 px-4 pt-4 rounded-lg border border-gray-200 dark:border-zinc-600`}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDropEvent}
|
||||
onFocus={handleEditorFocus}
|
||||
>
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<div className="relative w-full flex flex-row justify-between items-center pt-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<TagSelector onTagSelectorClick={(tag) => handleTagSelectorClick(tag)} />
|
||||
<IconButton
|
||||
className="flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow"
|
||||
onClick={handleUploadFileBtnClick}
|
||||
>
|
||||
<Icon.Image className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow"
|
||||
onClick={handleAddMemoRelationBtnClick}
|
||||
>
|
||||
<Icon.Link className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow"
|
||||
onClick={handleCheckBoxBtnClick}
|
||||
>
|
||||
<Icon.CheckSquare className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow"
|
||||
onClick={handleCodeBlockBtnClick}
|
||||
>
|
||||
<Icon.Code className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
|
||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
||||
<Divider className="!mt-2" />
|
||||
<div className="w-full flex flex-row justify-between items-center py-3 dark:border-t-zinc-500">
|
||||
<div className="relative flex flex-row justify-start items-center" onFocus={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
variant="plain"
|
||||
value={state.memoVisibility}
|
||||
startDecorator={<VisibilityIconV1 visibility={state.memoVisibility} />}
|
||||
onChange={(_, visibility) => {
|
||||
if (visibility) {
|
||||
handleMemoVisibilityChange(visibility);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC].map((item) => (
|
||||
<Option key={item} value={item} className="whitespace-nowrap">
|
||||
{t(`memo.visibility.${convertVisibilityToString(item).toLowerCase()}` as any)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-row justify-end items-center">
|
||||
<Button color="success" disabled={!allowSave} onClick={handleSaveBtnClick}>
|
||||
{t("editor.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoEditor;
|
@ -0,0 +1,83 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMemoV1Store } from "@/store/v1";
|
||||
import { MemoRelation } from "@/types/proto/api/v2/memo_relation_service";
|
||||
import { Memo } from "@/types/proto/api/v2/memo_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
relationList: MemoRelation[];
|
||||
}
|
||||
|
||||
const MemoRelationListViewV1 = (props: Props) => {
|
||||
const { memo, relationList } = props;
|
||||
const memoStore = useMemoV1Store();
|
||||
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
|
||||
const [referencedMemoList, setReferencedMemoList] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const referencingMemoList = await Promise.all(
|
||||
relationList
|
||||
.filter((relation) => relation.memoId === memo.id && relation.relatedMemoId !== memo.id)
|
||||
.map((relation) => memoStore.getOrFetchMemoById(relation.relatedMemoId))
|
||||
);
|
||||
setReferencingMemoList(referencingMemoList);
|
||||
const referencedMemoList = await Promise.all(
|
||||
relationList
|
||||
.filter((relation) => relation.memoId !== memo.id && relation.relatedMemoId === memo.id)
|
||||
.map((relation) => memoStore.getOrFetchMemoById(relation.memoId))
|
||||
);
|
||||
setReferencedMemoList(referencedMemoList);
|
||||
})();
|
||||
}, [memo, relationList]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{referencingMemoList.length > 0 && (
|
||||
<div className="w-full mt-2 flex flex-row justify-start items-center flex-wrap gap-2">
|
||||
{referencingMemoList.map((memo) => {
|
||||
return (
|
||||
<div key={memo.id} className="block w-auto max-w-[50%]">
|
||||
<Link
|
||||
className="px-2 border rounded-md w-auto text-sm leading-6 flex flex-row justify-start items-center flex-nowrap text-gray-600 dark:text-gray-300 dark:border-gray-600 hover:shadow hover:opacity-80"
|
||||
to={`/m/${memo.id}`}
|
||||
>
|
||||
<Tooltip title="Reference" placement="top">
|
||||
<Icon.Link className="w-4 h-auto shrink-0 opacity-70" />
|
||||
</Tooltip>
|
||||
<span className="opacity-70 mx-1">#{memo.id}</span>
|
||||
<span className="truncate">{memo.content}</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{referencedMemoList.length > 0 && (
|
||||
<div className="w-full mt-2 flex flex-row justify-start items-center flex-wrap gap-2">
|
||||
{referencedMemoList.map((memo) => {
|
||||
return (
|
||||
<div key={memo.id} className="block w-auto max-w-[50%]">
|
||||
<Link
|
||||
className="px-2 border rounded-md w-auto text-sm leading-6 flex flex-row justify-start items-center flex-nowrap text-gray-600 dark:text-gray-300 dark:border-gray-600 hover:shadow hover:opacity-80"
|
||||
to={`/m/${memo.id}`}
|
||||
>
|
||||
<Tooltip title="Backlink" placement="top">
|
||||
<Icon.Milestone className="w-4 h-auto shrink-0 opacity-70" />
|
||||
</Tooltip>
|
||||
<span className="opacity-70 mx-1">#{memo.id}</span>
|
||||
<span className="truncate">{memo.content}</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoRelationListViewV1;
|
@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
import MemoContentV1 from "@/components/MemoContentV1";
|
||||
import MemoResourceListView from "@/components/MemoResourceListView";
|
||||
import { getTimeString } from "@/helpers/datetime";
|
||||
import { useMemoV1Store } from "@/store/v1";
|
||||
import { MemoRelation, MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service";
|
||||
import { Memo } from "@/types/proto/api/v2/memo_service";
|
||||
import { Resource } from "@/types/proto/api/v2/resource_service";
|
||||
import MemoRelationListViewV1 from "./MemoRelationListViewV1";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
const TimelineMemo = (props: Props) => {
|
||||
const { memo } = props;
|
||||
const memoStore = useMemoV1Store();
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [relations, setRelations] = useState<MemoRelation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoStore.fetchMemoResources(memo.id).then((resources: Resource[]) => {
|
||||
setResources(resources);
|
||||
});
|
||||
memoStore.fetchMemoRelations(memo.id).then((relations: MemoRelation[]) => {
|
||||
setRelations(relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE));
|
||||
});
|
||||
}, [memo.id]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mt-0.5 mb-1 text-sm font-mono text-gray-500 dark:text-gray-400">
|
||||
<span className="opacity-80">{getTimeString(memo.displayTime)}</span>
|
||||
<Icon.Dot className="w-5 h-auto opacity-60" />
|
||||
<span className="opacity-60">#{memo.id}</span>
|
||||
</div>
|
||||
<MemoContentV1 content={memo.content} nodes={memo.nodes} />
|
||||
<MemoResourceListView resourceList={resources} />
|
||||
<MemoRelationListViewV1 memo={memo} relationList={relations} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineMemo;
|
@ -0,0 +1,27 @@
|
||||
import classNames from "classnames";
|
||||
import { Visibility } from "@/types/proto/api/v2/memo_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
const VisibilityIcon = (props: Props) => {
|
||||
const { visibility } = props;
|
||||
|
||||
let VIcon = null;
|
||||
if (visibility === Visibility.PRIVATE) {
|
||||
VIcon = Icon.Lock;
|
||||
} else if (visibility === Visibility.PROTECTED) {
|
||||
VIcon = Icon.Users;
|
||||
} else if (visibility === Visibility.PUBLIC) {
|
||||
VIcon = Icon.Globe2;
|
||||
}
|
||||
if (!VIcon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <VIcon className={classNames("w-4 h-auto text-gray-400")} />;
|
||||
};
|
||||
|
||||
export default VisibilityIcon;
|
@ -0,0 +1,27 @@
|
||||
import { Visibility } from "@/types/proto/api/v2/memo_service";
|
||||
|
||||
export const convertVisibilityFromString = (visibility: string) => {
|
||||
switch (visibility) {
|
||||
case "PUBLIC":
|
||||
return Visibility.PUBLIC;
|
||||
case "PROTECTED":
|
||||
return Visibility.PROTECTED;
|
||||
case "PRIVATE":
|
||||
return Visibility.PRIVATE;
|
||||
default:
|
||||
return Visibility.PUBLIC;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertVisibilityToString = (visibility: Visibility) => {
|
||||
switch (visibility) {
|
||||
case Visibility.PUBLIC:
|
||||
return "PUBLIC";
|
||||
case Visibility.PROTECTED:
|
||||
return "PROTECTED";
|
||||
case Visibility.PRIVATE:
|
||||
return "PRIVATE";
|
||||
default:
|
||||
return "PRIVATE";
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue