feat: implement switchable task list node

This commit is contained in:
Steven 2024-01-05 08:40:16 +08:00
parent 6320d042c8
commit 454cd4e24f
16 changed files with 208 additions and 38 deletions

View file

@ -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)

View file

@ -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

View 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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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>")
}

View file

@ -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
}
}

View file

@ -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:

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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: [],
});

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

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

View file

@ -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">