mirror of
https://github.com/usememos/memos.git
synced 2024-12-26 15:13:10 +08:00
feat: implement switchable task list node
This commit is contained in:
parent
6320d042c8
commit
454cd4e24f
16 changed files with 208 additions and 38 deletions
|
@ -95,6 +95,73 @@ func convertFromASTNode(rawNode ast.Node) *apiv2pb.Node {
|
|||
return node
|
||||
}
|
||||
|
||||
func convertToASTNodes(nodes []*apiv2pb.Node) []ast.Node {
|
||||
rawNodes := []ast.Node{}
|
||||
for _, node := range nodes {
|
||||
rawNode := convertToASTNode(node)
|
||||
rawNodes = append(rawNodes, rawNode)
|
||||
}
|
||||
return rawNodes
|
||||
}
|
||||
|
||||
func convertToASTNode(node *apiv2pb.Node) ast.Node {
|
||||
switch n := node.Node.(type) {
|
||||
case *apiv2pb.Node_LineBreakNode:
|
||||
return &ast.LineBreak{}
|
||||
case *apiv2pb.Node_ParagraphNode:
|
||||
children := convertToASTNodes(n.ParagraphNode.Children)
|
||||
return &ast.Paragraph{Children: children}
|
||||
case *apiv2pb.Node_CodeBlockNode:
|
||||
return &ast.CodeBlock{Language: n.CodeBlockNode.Language, Content: n.CodeBlockNode.Content}
|
||||
case *apiv2pb.Node_HeadingNode:
|
||||
children := convertToASTNodes(n.HeadingNode.Children)
|
||||
return &ast.Heading{Level: int(n.HeadingNode.Level), Children: children}
|
||||
case *apiv2pb.Node_HorizontalRuleNode:
|
||||
return &ast.HorizontalRule{Symbol: n.HorizontalRuleNode.Symbol}
|
||||
case *apiv2pb.Node_BlockquoteNode:
|
||||
children := convertToASTNodes(n.BlockquoteNode.Children)
|
||||
return &ast.Blockquote{Children: children}
|
||||
case *apiv2pb.Node_OrderedListNode:
|
||||
children := convertToASTNodes(n.OrderedListNode.Children)
|
||||
return &ast.OrderedList{Number: n.OrderedListNode.Number, Children: children}
|
||||
case *apiv2pb.Node_UnorderedListNode:
|
||||
children := convertToASTNodes(n.UnorderedListNode.Children)
|
||||
return &ast.UnorderedList{Symbol: n.UnorderedListNode.Symbol, Children: children}
|
||||
case *apiv2pb.Node_TaskListNode:
|
||||
children := convertToASTNodes(n.TaskListNode.Children)
|
||||
return &ast.TaskList{Symbol: n.TaskListNode.Symbol, Complete: n.TaskListNode.Complete, Children: children}
|
||||
case *apiv2pb.Node_MathBlockNode:
|
||||
return &ast.MathBlock{Content: n.MathBlockNode.Content}
|
||||
case *apiv2pb.Node_TextNode:
|
||||
return &ast.Text{Content: n.TextNode.Content}
|
||||
case *apiv2pb.Node_BoldNode:
|
||||
children := convertToASTNodes(n.BoldNode.Children)
|
||||
return &ast.Bold{Symbol: n.BoldNode.Symbol, Children: children}
|
||||
case *apiv2pb.Node_ItalicNode:
|
||||
return &ast.Italic{Symbol: n.ItalicNode.Symbol, Content: n.ItalicNode.Content}
|
||||
case *apiv2pb.Node_BoldItalicNode:
|
||||
return &ast.BoldItalic{Symbol: n.BoldItalicNode.Symbol, Content: n.BoldItalicNode.Content}
|
||||
case *apiv2pb.Node_CodeNode:
|
||||
return &ast.Code{Content: n.CodeNode.Content}
|
||||
case *apiv2pb.Node_ImageNode:
|
||||
return &ast.Image{AltText: n.ImageNode.AltText, URL: n.ImageNode.Url}
|
||||
case *apiv2pb.Node_LinkNode:
|
||||
return &ast.Link{Text: n.LinkNode.Text, URL: n.LinkNode.Url}
|
||||
case *apiv2pb.Node_AutoLinkNode:
|
||||
return &ast.AutoLink{URL: n.AutoLinkNode.Url}
|
||||
case *apiv2pb.Node_TagNode:
|
||||
return &ast.Tag{Content: n.TagNode.Content}
|
||||
case *apiv2pb.Node_StrikethroughNode:
|
||||
return &ast.Strikethrough{Content: n.StrikethroughNode.Content}
|
||||
case *apiv2pb.Node_EscapingCharacterNode:
|
||||
return &ast.EscapingCharacter{Symbol: n.EscapingCharacterNode.Symbol}
|
||||
case *apiv2pb.Node_MathNode:
|
||||
return &ast.Math{Content: n.MathNode.Content}
|
||||
default:
|
||||
return &ast.Text{}
|
||||
}
|
||||
}
|
||||
|
||||
func traverseASTNodes(nodes []ast.Node, fn func(ast.Node)) {
|
||||
for _, node := range nodes {
|
||||
fn(node)
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
"github.com/usememos/memos/plugin/webhook"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
|
@ -232,6 +233,10 @@ func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMe
|
|||
}
|
||||
}
|
||||
})
|
||||
} else if path == "nodes" {
|
||||
nodes := convertToASTNodes(request.Memo.Nodes)
|
||||
content := restore.Restore(nodes)
|
||||
update.Content = &content
|
||||
} else if path == "visibility" {
|
||||
visibility := convertVisibilityToStore(request.Memo.Visibility)
|
||||
update.Visibility = &visibility
|
||||
|
|
23
plugin/gomark/ast/utils.go
Normal file
23
plugin/gomark/ast/utils.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package ast
|
||||
|
||||
func FindPrevSiblingExceptLineBreak(node Node) Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
prev := node.PrevSibling()
|
||||
if prev != nil && prev.Type() == LineBreakNode {
|
||||
return FindPrevSiblingExceptLineBreak(prev)
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
func FindNextSiblingExceptLineBreak(node Node) Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
next := node.NextSibling()
|
||||
if next != nil && next.Type() == LineBreakNode {
|
||||
return FindNextSiblingExceptLineBreak(next)
|
||||
}
|
||||
return next
|
||||
}
|
|
@ -49,7 +49,6 @@ func ParseBlock(tokens []*tokenizer.Token) ([]ast.Node, error) {
|
|||
func ParseBlockWithParsers(tokens []*tokenizer.Token, blockParsers []BlockParser) ([]ast.Node, error) {
|
||||
nodes := []ast.Node{}
|
||||
var prevNode ast.Node
|
||||
var skipNextLineBreakFlag bool
|
||||
for len(tokens) > 0 {
|
||||
for _, blockParser := range blockParsers {
|
||||
size, matched := blockParser.Match(tokens)
|
||||
|
@ -59,21 +58,12 @@ func ParseBlockWithParsers(tokens []*tokenizer.Token, blockParsers []BlockParser
|
|||
return nil, errors.New("parse error")
|
||||
}
|
||||
|
||||
if node.Type() == ast.LineBreakNode && skipNextLineBreakFlag {
|
||||
if prevNode != nil && ast.IsBlockNode(prevNode) {
|
||||
tokens = tokens[size:]
|
||||
skipNextLineBreakFlag = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokens = tokens[size:]
|
||||
if prevNode != nil {
|
||||
prevNode.SetNextSibling(node)
|
||||
node.SetPrevSibling(prevNode)
|
||||
}
|
||||
prevNode = node
|
||||
skipNextLineBreakFlag = true
|
||||
nodes = append(nodes, node)
|
||||
break
|
||||
}
|
||||
|
|
|
@ -96,6 +96,7 @@ func TestParser(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
|
@ -126,6 +127,7 @@ func TestParser(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
&ast.CodeBlock{
|
||||
Language: "javascript",
|
||||
Content: "console.log(\"Hello world!\");",
|
||||
|
@ -143,6 +145,7 @@ func TestParser(t *testing.T) {
|
|||
},
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
&ast.LineBreak{},
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
|
@ -163,6 +166,7 @@ func TestParser(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
&ast.TaskList{
|
||||
Symbol: tokenizer.Hyphen,
|
||||
Complete: false,
|
||||
|
@ -186,6 +190,7 @@ func TestParser(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
&ast.TaskList{
|
||||
Symbol: tokenizer.Hyphen,
|
||||
Complete: true,
|
||||
|
|
|
@ -72,8 +72,19 @@ func (r *HTMLRenderer) RenderNode(node ast.Node) {
|
|||
|
||||
// RenderNodes renders a slice of AST nodes to HTML.
|
||||
func (r *HTMLRenderer) RenderNodes(nodes []ast.Node) {
|
||||
var prevNode ast.Node
|
||||
var skipNextLineBreakFlag bool
|
||||
for _, node := range nodes {
|
||||
if node.Type() == ast.LineBreakNode && skipNextLineBreakFlag {
|
||||
if prevNode != nil && ast.IsBlockNode(prevNode) {
|
||||
skipNextLineBreakFlag = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
r.RenderNode(node)
|
||||
prevNode = node
|
||||
skipNextLineBreakFlag = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,7 +122,7 @@ func (r *HTMLRenderer) renderHorizontalRule(_ *ast.HorizontalRule) {
|
|||
}
|
||||
|
||||
func (r *HTMLRenderer) renderBlockquote(node *ast.Blockquote) {
|
||||
prevSibling, nextSibling := node.PrevSibling(), node.NextSibling()
|
||||
prevSibling, nextSibling := ast.FindPrevSiblingExceptLineBreak(node), ast.FindNextSiblingExceptLineBreak(node)
|
||||
if prevSibling == nil || prevSibling.Type() != ast.BlockquoteNode {
|
||||
r.output.WriteString("<blockquote>")
|
||||
}
|
||||
|
@ -122,7 +133,7 @@ func (r *HTMLRenderer) renderBlockquote(node *ast.Blockquote) {
|
|||
}
|
||||
|
||||
func (r *HTMLRenderer) renderTaskList(node *ast.TaskList) {
|
||||
prevSibling, nextSibling := node.PrevSibling(), node.NextSibling()
|
||||
prevSibling, nextSibling := ast.FindPrevSiblingExceptLineBreak(node), ast.FindNextSiblingExceptLineBreak(node)
|
||||
if prevSibling == nil || prevSibling.Type() != ast.TaskListNode {
|
||||
r.output.WriteString("<ul>")
|
||||
}
|
||||
|
@ -140,7 +151,7 @@ func (r *HTMLRenderer) renderTaskList(node *ast.TaskList) {
|
|||
}
|
||||
|
||||
func (r *HTMLRenderer) renderUnorderedList(node *ast.UnorderedList) {
|
||||
prevSibling, nextSibling := node.PrevSibling(), node.NextSibling()
|
||||
prevSibling, nextSibling := ast.FindPrevSiblingExceptLineBreak(node), ast.FindNextSiblingExceptLineBreak(node)
|
||||
if prevSibling == nil || prevSibling.Type() != ast.UnorderedListNode {
|
||||
r.output.WriteString("<ul>")
|
||||
}
|
||||
|
@ -153,7 +164,7 @@ func (r *HTMLRenderer) renderUnorderedList(node *ast.UnorderedList) {
|
|||
}
|
||||
|
||||
func (r *HTMLRenderer) renderOrderedList(node *ast.OrderedList) {
|
||||
prevSibling, nextSibling := node.PrevSibling(), node.NextSibling()
|
||||
prevSibling, nextSibling := ast.FindPrevSiblingExceptLineBreak(node), ast.FindNextSiblingExceptLineBreak(node)
|
||||
if prevSibling == nil || prevSibling.Type() != ast.OrderedListNode {
|
||||
r.output.WriteString("<ol>")
|
||||
}
|
||||
|
|
|
@ -72,8 +72,19 @@ func (r *StringRenderer) RenderNode(node ast.Node) {
|
|||
|
||||
// RenderNodes renders a slice of AST nodes to raw string.
|
||||
func (r *StringRenderer) RenderNodes(nodes []ast.Node) {
|
||||
var prevNode ast.Node
|
||||
var skipNextLineBreakFlag bool
|
||||
for _, node := range nodes {
|
||||
if node.Type() == ast.LineBreakNode && skipNextLineBreakFlag {
|
||||
if prevNode != nil && ast.IsBlockNode(prevNode) {
|
||||
skipNextLineBreakFlag = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
r.RenderNode(node)
|
||||
prevNode = node
|
||||
skipNextLineBreakFlag = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -44,10 +44,11 @@ import Text from "./Text";
|
|||
import UnorderedList from "./UnorderedList";
|
||||
|
||||
interface Props {
|
||||
index: string;
|
||||
node: Node;
|
||||
}
|
||||
|
||||
const Renderer: React.FC<Props> = ({ node }: Props) => {
|
||||
const Renderer: React.FC<Props> = ({ index, node }: Props) => {
|
||||
switch (node.type) {
|
||||
case NodeType.LINE_BREAK:
|
||||
return <LineBreak />;
|
||||
|
@ -66,7 +67,7 @@ const Renderer: React.FC<Props> = ({ node }: Props) => {
|
|||
case NodeType.UNORDERED_LIST:
|
||||
return <UnorderedList {...(node.unorderedListNode as UnorderedListNode)} />;
|
||||
case NodeType.TASK_LIST:
|
||||
return <TaskList {...(node.taskListNode as TaskListNode)} />;
|
||||
return <TaskList index={index} {...(node.taskListNode as TaskListNode)} />;
|
||||
case NodeType.MATH_BLOCK:
|
||||
return <Math {...(node.mathBlockNode as MathNode)} block={true} />;
|
||||
case NodeType.TEXT:
|
||||
|
|
|
@ -1,23 +1,51 @@
|
|||
import { Checkbox } from "@mui/joy";
|
||||
import { Node } from "@/types/proto/api/v2/markdown_service";
|
||||
import { useContext } from "react";
|
||||
import { useMemoStore } from "@/store/v1";
|
||||
import { Node, NodeType } from "@/types/proto/api/v2/markdown_service";
|
||||
import Renderer from "./Renderer";
|
||||
import { RendererContext } from "./types";
|
||||
|
||||
interface Props {
|
||||
index: string;
|
||||
symbol: string;
|
||||
complete: boolean;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
const TaskList: React.FC<Props> = ({ complete, children }: Props) => {
|
||||
const TaskList: React.FC<Props> = ({ index, complete, children }: Props) => {
|
||||
const context = useContext(RendererContext);
|
||||
const memoStore = useMemoStore();
|
||||
|
||||
const handleCheckboxChange = async (on: boolean) => {
|
||||
const nodeIndex = Number(index);
|
||||
if (isNaN(nodeIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = context.nodes[nodeIndex];
|
||||
if (node.type !== NodeType.TASK_LIST || !node.taskListNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.taskListNode!.complete = on;
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
id: context.memoId,
|
||||
nodes: context.nodes,
|
||||
},
|
||||
["nodes"]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li className="grid grid-cols-[24px_1fr] gap-1">
|
||||
<div className="w-7 h-6 flex justify-center items-center">
|
||||
<Checkbox size="sm" checked={complete} readOnly />
|
||||
<Checkbox size="sm" checked={complete} disabled={context.readonly} onChange={(e) => handleCheckboxChange(e.target.checked)} />
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child, index) => (
|
||||
<Renderer key={`${child.type}-${index}`} node={child} />
|
||||
{children.map((child, subIndex) => (
|
||||
<Renderer key={`${child.type}-${subIndex}`} index={`${index}-${subIndex}`} node={child} />
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
import { useRef } from "react";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useMemoStore } from "@/store/v1";
|
||||
import { Node } from "@/types/proto/api/v2/markdown_service";
|
||||
import Renderer from "./Renderer";
|
||||
import { RendererContext } from "./types";
|
||||
|
||||
interface Props {
|
||||
memoId: number;
|
||||
nodes: Node[];
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
onMemoContentClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
const { className, onMemoContentClick } = props;
|
||||
const { className, memoId, nodes, onMemoContentClick } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const memoStore = useMemoStore();
|
||||
const memoContentContainerRef = useRef<HTMLDivElement>(null);
|
||||
const allowEdit = currentUser?.id === memoStore.getMemoById(memoId)?.creatorId && !props.readonly;
|
||||
|
||||
const handleMemoContentClick = async (e: React.MouseEvent) => {
|
||||
if (onMemoContentClick) {
|
||||
|
@ -19,17 +27,25 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
className="w-full max-w-full word-break text-base leading-6 space-y-1"
|
||||
onClick={handleMemoContentClick}
|
||||
>
|
||||
{props.nodes.map((node, index) => (
|
||||
<Renderer key={`${node.type}-${index}`} node={node} />
|
||||
))}
|
||||
<RendererContext.Provider
|
||||
value={{
|
||||
memoId,
|
||||
nodes,
|
||||
readonly: !allowEdit,
|
||||
}}
|
||||
>
|
||||
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
className="w-full max-w-full word-break text-base leading-6 space-y-1"
|
||||
onClick={handleMemoContentClick}
|
||||
>
|
||||
{nodes.map((node, index) => (
|
||||
<Renderer key={`${node.type}-${index}`} index={String(index)} node={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RendererContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1 +1,14 @@
|
|||
export interface RendererContext {}
|
||||
import { createContext } from "react";
|
||||
import { UNKNOWN_ID } from "@/helpers/consts";
|
||||
import { Node } from "@/types/proto/api/v2/markdown_service";
|
||||
|
||||
interface Context {
|
||||
memoId: number;
|
||||
nodes: Node[];
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const RendererContext = createContext<Context>({
|
||||
memoId: UNKNOWN_ID,
|
||||
nodes: [],
|
||||
});
|
||||
|
|
|
@ -251,7 +251,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MemoContent nodes={memo.nodes} onMemoContentClick={handleMemoContentClick} />
|
||||
<MemoContent memoId={memo.id} nodes={memo.nodes} onMemoContentClick={handleMemoContentClick} />
|
||||
<MemoResourceListView resourceList={memo.resources} />
|
||||
<MemoRelationListView memo={memo} relationList={referenceRelations} />
|
||||
</div>
|
||||
|
|
|
@ -100,7 +100,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
|||
>
|
||||
<span className="w-full px-6 pt-5 pb-2 text-sm text-gray-500">{getDateTimeString(memo.displayTime)}</span>
|
||||
<div className="w-full px-6 text-base pb-4">
|
||||
<MemoContent nodes={memo.nodes} />
|
||||
<MemoContent memoId={memo.id} nodes={memo.nodes} readonly={true} />
|
||||
<MemoResourceListView resourceList={memo.resources} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center w-full bg-gray-100 dark:bg-zinc-900 py-4 px-6">
|
||||
|
|
|
@ -18,7 +18,7 @@ const TimelineMemo = (props: Props) => {
|
|||
<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>
|
||||
</div>
|
||||
<MemoContent nodes={memo.nodes} />
|
||||
<MemoContent memoId={memo.id} nodes={memo.nodes} />
|
||||
<MemoResourceListView resourceList={memo.resources} />
|
||||
<MemoRelationListView memo={memo} relationList={relations} />
|
||||
</div>
|
||||
|
|
|
@ -105,7 +105,7 @@ const Archived = () => {
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<MemoContent nodes={memo.nodes} />
|
||||
<MemoContent memoId={memo.id} nodes={memo.nodes} readonly={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -139,7 +139,7 @@ const MemoDetail = () => {
|
|||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<MemoContent nodes={memo.nodes} />
|
||||
<MemoContent memoId={memo.id} nodes={memo.nodes} readonly={true} />
|
||||
<MemoResourceListView resourceList={memo.resources} />
|
||||
<MemoRelationListView memo={memo} relationList={referenceRelations} />
|
||||
<div className="w-full mt-3 flex flex-row justify-between items-center gap-2">
|
||||
|
|
Loading…
Reference in a new issue