feat: implement switchable task list node

pull/2716/head
Steven 1 year ago
parent 6320d042c8
commit 454cd4e24f

@ -95,6 +95,73 @@ func convertFromASTNode(rawNode ast.Node) *apiv2pb.Node {
return 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)) { func traverseASTNodes(nodes []ast.Node, fn func(ast.Node)) {
for _, node := range nodes { for _, node := range nodes {
fn(node) fn(node)

@ -19,6 +19,7 @@ import (
"github.com/usememos/memos/plugin/gomark/ast" "github.com/usememos/memos/plugin/gomark/ast"
"github.com/usememos/memos/plugin/gomark/parser" "github.com/usememos/memos/plugin/gomark/parser"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer" "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
"github.com/usememos/memos/plugin/gomark/restore"
"github.com/usememos/memos/plugin/webhook" "github.com/usememos/memos/plugin/webhook"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2" apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
storepb "github.com/usememos/memos/proto/gen/store" 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" { } else if path == "visibility" {
visibility := convertVisibilityToStore(request.Memo.Visibility) visibility := convertVisibilityToStore(request.Memo.Visibility)
update.Visibility = &visibility update.Visibility = &visibility

@ -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) { func ParseBlockWithParsers(tokens []*tokenizer.Token, blockParsers []BlockParser) ([]ast.Node, error) {
nodes := []ast.Node{} nodes := []ast.Node{}
var prevNode ast.Node var prevNode ast.Node
var skipNextLineBreakFlag bool
for len(tokens) > 0 { for len(tokens) > 0 {
for _, blockParser := range blockParsers { for _, blockParser := range blockParsers {
size, matched := blockParser.Match(tokens) size, matched := blockParser.Match(tokens)
@ -59,21 +58,12 @@ func ParseBlockWithParsers(tokens []*tokenizer.Token, blockParsers []BlockParser
return nil, errors.New("parse error") 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:] tokens = tokens[size:]
if prevNode != nil { if prevNode != nil {
prevNode.SetNextSibling(node) prevNode.SetNextSibling(node)
node.SetPrevSibling(prevNode) node.SetPrevSibling(prevNode)
} }
prevNode = node prevNode = node
skipNextLineBreakFlag = true
nodes = append(nodes, node) nodes = append(nodes, node)
break break
} }

@ -96,6 +96,7 @@ func TestParser(t *testing.T) {
}, },
}, },
}, },
&ast.LineBreak{},
&ast.Paragraph{ &ast.Paragraph{
Children: []ast.Node{ Children: []ast.Node{
&ast.Text{ &ast.Text{
@ -126,6 +127,7 @@ func TestParser(t *testing.T) {
}, },
}, },
}, },
&ast.LineBreak{},
&ast.CodeBlock{ &ast.CodeBlock{
Language: "javascript", Language: "javascript",
Content: "console.log(\"Hello world!\");", Content: "console.log(\"Hello world!\");",
@ -143,6 +145,7 @@ func TestParser(t *testing.T) {
}, },
}, },
&ast.LineBreak{}, &ast.LineBreak{},
&ast.LineBreak{},
&ast.Paragraph{ &ast.Paragraph{
Children: []ast.Node{ Children: []ast.Node{
&ast.Text{ &ast.Text{
@ -163,6 +166,7 @@ func TestParser(t *testing.T) {
}, },
}, },
}, },
&ast.LineBreak{},
&ast.TaskList{ &ast.TaskList{
Symbol: tokenizer.Hyphen, Symbol: tokenizer.Hyphen,
Complete: false, Complete: false,
@ -186,6 +190,7 @@ func TestParser(t *testing.T) {
}, },
}, },
}, },
&ast.LineBreak{},
&ast.TaskList{ &ast.TaskList{
Symbol: tokenizer.Hyphen, Symbol: tokenizer.Hyphen,
Complete: true, Complete: true,

@ -72,8 +72,19 @@ func (r *HTMLRenderer) RenderNode(node ast.Node) {
// RenderNodes renders a slice of AST nodes to HTML. // RenderNodes renders a slice of AST nodes to HTML.
func (r *HTMLRenderer) RenderNodes(nodes []ast.Node) { func (r *HTMLRenderer) RenderNodes(nodes []ast.Node) {
var prevNode ast.Node
var skipNextLineBreakFlag bool
for _, node := range nodes { for _, node := range nodes {
if node.Type() == ast.LineBreakNode && skipNextLineBreakFlag {
if prevNode != nil && ast.IsBlockNode(prevNode) {
skipNextLineBreakFlag = false
continue
}
}
r.RenderNode(node) r.RenderNode(node)
prevNode = node
skipNextLineBreakFlag = true
} }
} }
@ -111,7 +122,7 @@ func (r *HTMLRenderer) renderHorizontalRule(_ *ast.HorizontalRule) {
} }
func (r *HTMLRenderer) renderBlockquote(node *ast.Blockquote) { 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 { if prevSibling == nil || prevSibling.Type() != ast.BlockquoteNode {
r.output.WriteString("<blockquote>") r.output.WriteString("<blockquote>")
} }
@ -122,7 +133,7 @@ func (r *HTMLRenderer) renderBlockquote(node *ast.Blockquote) {
} }
func (r *HTMLRenderer) renderTaskList(node *ast.TaskList) { 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 { if prevSibling == nil || prevSibling.Type() != ast.TaskListNode {
r.output.WriteString("<ul>") r.output.WriteString("<ul>")
} }
@ -140,7 +151,7 @@ func (r *HTMLRenderer) renderTaskList(node *ast.TaskList) {
} }
func (r *HTMLRenderer) renderUnorderedList(node *ast.UnorderedList) { 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 { if prevSibling == nil || prevSibling.Type() != ast.UnorderedListNode {
r.output.WriteString("<ul>") r.output.WriteString("<ul>")
} }
@ -153,7 +164,7 @@ func (r *HTMLRenderer) renderUnorderedList(node *ast.UnorderedList) {
} }
func (r *HTMLRenderer) renderOrderedList(node *ast.OrderedList) { 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 { if prevSibling == nil || prevSibling.Type() != ast.OrderedListNode {
r.output.WriteString("<ol>") 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. // RenderNodes renders a slice of AST nodes to raw string.
func (r *StringRenderer) RenderNodes(nodes []ast.Node) { func (r *StringRenderer) RenderNodes(nodes []ast.Node) {
var prevNode ast.Node
var skipNextLineBreakFlag bool
for _, node := range nodes { for _, node := range nodes {
if node.Type() == ast.LineBreakNode && skipNextLineBreakFlag {
if prevNode != nil && ast.IsBlockNode(prevNode) {
skipNextLineBreakFlag = false
continue
}
}
r.RenderNode(node) r.RenderNode(node)
prevNode = node
skipNextLineBreakFlag = true
} }
} }

@ -44,10 +44,11 @@ import Text from "./Text";
import UnorderedList from "./UnorderedList"; import UnorderedList from "./UnorderedList";
interface Props { interface Props {
index: string;
node: Node; node: Node;
} }
const Renderer: React.FC<Props> = ({ node }: Props) => { const Renderer: React.FC<Props> = ({ index, node }: Props) => {
switch (node.type) { switch (node.type) {
case NodeType.LINE_BREAK: case NodeType.LINE_BREAK:
return <LineBreak />; return <LineBreak />;
@ -66,7 +67,7 @@ const Renderer: React.FC<Props> = ({ node }: Props) => {
case NodeType.UNORDERED_LIST: case NodeType.UNORDERED_LIST:
return <UnorderedList {...(node.unorderedListNode as UnorderedListNode)} />; return <UnorderedList {...(node.unorderedListNode as UnorderedListNode)} />;
case NodeType.TASK_LIST: case NodeType.TASK_LIST:
return <TaskList {...(node.taskListNode as TaskListNode)} />; return <TaskList index={index} {...(node.taskListNode as TaskListNode)} />;
case NodeType.MATH_BLOCK: case NodeType.MATH_BLOCK:
return <Math {...(node.mathBlockNode as MathNode)} block={true} />; return <Math {...(node.mathBlockNode as MathNode)} block={true} />;
case NodeType.TEXT: case NodeType.TEXT:

@ -1,23 +1,51 @@
import { Checkbox } from "@mui/joy"; 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 Renderer from "./Renderer";
import { RendererContext } from "./types";
interface Props { interface Props {
index: string;
symbol: string; symbol: string;
complete: boolean; complete: boolean;
children: Node[]; 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 ( return (
<ul> <ul>
<li className="grid grid-cols-[24px_1fr] gap-1"> <li className="grid grid-cols-[24px_1fr] gap-1">
<div className="w-7 h-6 flex justify-center items-center"> <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>
<div> <div>
{children.map((child, index) => ( {children.map((child, subIndex) => (
<Renderer key={`${child.type}-${index}`} node={child} /> <Renderer key={`${child.type}-${subIndex}`} index={`${index}-${subIndex}`} node={child} />
))} ))}
</div> </div>
</li> </li>

@ -1,16 +1,24 @@
import { useRef } from "react"; import { useRef } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoStore } from "@/store/v1";
import { Node } from "@/types/proto/api/v2/markdown_service"; import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer"; import Renderer from "./Renderer";
import { RendererContext } from "./types";
interface Props { interface Props {
memoId: number;
nodes: Node[]; nodes: Node[];
readonly?: boolean;
className?: string; className?: string;
onMemoContentClick?: (e: React.MouseEvent) => void; onMemoContentClick?: (e: React.MouseEvent) => void;
} }
const MemoContent: React.FC<Props> = (props: Props) => { 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 memoContentContainerRef = useRef<HTMLDivElement>(null);
const allowEdit = currentUser?.id === memoStore.getMemoById(memoId)?.creatorId && !props.readonly;
const handleMemoContentClick = async (e: React.MouseEvent) => { const handleMemoContentClick = async (e: React.MouseEvent) => {
if (onMemoContentClick) { if (onMemoContentClick) {
@ -19,17 +27,25 @@ const MemoContent: React.FC<Props> = (props: Props) => {
}; };
return ( return (
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}> <RendererContext.Provider
<div value={{
ref={memoContentContainerRef} memoId,
className="w-full max-w-full word-break text-base leading-6 space-y-1" nodes,
onClick={handleMemoContentClick} readonly: !allowEdit,
> }}
{props.nodes.map((node, index) => ( >
<Renderer key={`${node.type}-${index}`} node={node} /> <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>
</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>
</div> </div>
<MemoContent nodes={memo.nodes} onMemoContentClick={handleMemoContentClick} /> <MemoContent memoId={memo.id} nodes={memo.nodes} onMemoContentClick={handleMemoContentClick} />
<MemoResourceListView resourceList={memo.resources} /> <MemoResourceListView resourceList={memo.resources} />
<MemoRelationListView memo={memo} relationList={referenceRelations} /> <MemoRelationListView memo={memo} relationList={referenceRelations} />
</div> </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> <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"> <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} /> <MemoResourceListView resourceList={memo.resources} />
</div> </div>
<div className="flex flex-row justify-between items-center w-full bg-gray-100 dark:bg-zinc-900 py-4 px-6"> <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"> <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> <span className="opacity-80">{getTimeString(memo.displayTime)}</span>
</div> </div>
<MemoContent nodes={memo.nodes} /> <MemoContent memoId={memo.id} nodes={memo.nodes} />
<MemoResourceListView resourceList={memo.resources} /> <MemoResourceListView resourceList={memo.resources} />
<MemoRelationListView memo={memo} relationList={relations} /> <MemoRelationListView memo={memo} relationList={relations} />
</div> </div>

@ -105,7 +105,7 @@ const Archived = () => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<MemoContent nodes={memo.nodes} /> <MemoContent memoId={memo.id} nodes={memo.nodes} readonly={true} />
</div> </div>
))} ))}
</div> </div>

@ -139,7 +139,7 @@ const MemoDetail = () => {
</Link> </Link>
</div> </div>
)} )}
<MemoContent nodes={memo.nodes} /> <MemoContent memoId={memo.id} nodes={memo.nodes} readonly={true} />
<MemoResourceListView resourceList={memo.resources} /> <MemoResourceListView resourceList={memo.resources} />
<MemoRelationListView memo={memo} relationList={referenceRelations} /> <MemoRelationListView memo={memo} relationList={referenceRelations} />
<div className="w-full mt-3 flex flex-row justify-between items-center gap-2"> <div className="w-full mt-3 flex flex-row justify-between items-center gap-2">

Loading…
Cancel
Save