feat: implement embedded memo renderer

pull/2799/head
Steven 1 year ago
parent 67f5ac3657
commit 8a34013558

@ -65,6 +65,8 @@ func convertFromASTNode(rawNode ast.Node) *apiv2pb.Node {
node.Node = &apiv2pb.Node_MathBlockNode{MathBlockNode: &apiv2pb.MathBlockNode{Content: n.Content}} node.Node = &apiv2pb.Node_MathBlockNode{MathBlockNode: &apiv2pb.MathBlockNode{Content: n.Content}}
case *ast.Table: case *ast.Table:
node.Node = &apiv2pb.Node_TableNode{TableNode: convertTableFromASTNode(n)} node.Node = &apiv2pb.Node_TableNode{TableNode: convertTableFromASTNode(n)}
case *ast.EmbeddedContent:
node.Node = &apiv2pb.Node_EmbeddedContentNode{EmbeddedContentNode: &apiv2pb.EmbeddedContentNode{ResourceName: n.ResourceName}}
case *ast.Text: case *ast.Text:
node.Node = &apiv2pb.Node_TextNode{TextNode: &apiv2pb.TextNode{Content: n.Content}} node.Node = &apiv2pb.Node_TextNode{TextNode: &apiv2pb.TextNode{Content: n.Content}}
case *ast.Bold: case *ast.Bold:
@ -142,6 +144,8 @@ func convertToASTNode(node *apiv2pb.Node) ast.Node {
return &ast.MathBlock{Content: n.MathBlockNode.Content} return &ast.MathBlock{Content: n.MathBlockNode.Content}
case *apiv2pb.Node_TableNode: case *apiv2pb.Node_TableNode:
return convertTableToASTNode(node) return convertTableToASTNode(node)
case *apiv2pb.Node_EmbeddedContentNode:
return &ast.EmbeddedContent{ResourceName: n.EmbeddedContentNode.ResourceName}
case *apiv2pb.Node_TextNode: case *apiv2pb.Node_TextNode:
return &ast.Text{Content: n.TextNode.Content} return &ast.Text{Content: n.TextNode.Content}
case *apiv2pb.Node_BoldNode: case *apiv2pb.Node_BoldNode:

@ -16,6 +16,7 @@ const (
TaskListNode TaskListNode
MathBlockNode MathBlockNode
TableNode TableNode
EmbeddedContentNode
// Inline nodes. // Inline nodes.
TextNode TextNode
BoldNode BoldNode

@ -228,3 +228,17 @@ func (n *Table) Restore() string {
} }
return result return result
} }
type EmbeddedContent struct {
BaseBlock
ResourceName string
}
func (*EmbeddedContent) Type() NodeType {
return EmbeddedContentNode
}
func (n *EmbeddedContent) Restore() string {
return fmt.Sprintf("![[%s]]", n.ResourceName)
}

@ -0,0 +1,51 @@
package parser
import (
"errors"
"github.com/usememos/memos/plugin/gomark/ast"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
type EmbeddedContentParser struct{}
func NewEmbeddedContentParser() *EmbeddedContentParser {
return &EmbeddedContentParser{}
}
func (*EmbeddedContentParser) Match(tokens []*tokenizer.Token) (int, bool) {
lines := tokenizer.Split(tokens, tokenizer.Newline)
if len(lines) < 1 {
return 0, false
}
firstLine := lines[0]
if len(firstLine) < 5 {
return 0, false
}
if firstLine[0].Type != tokenizer.ExclamationMark || firstLine[1].Type != tokenizer.LeftSquareBracket || firstLine[2].Type != tokenizer.LeftSquareBracket {
return 0, false
}
matched := false
for index, token := range firstLine[:len(firstLine)-1] {
if token.Type == tokenizer.RightSquareBracket && firstLine[index+1].Type == tokenizer.RightSquareBracket && index+1 == len(firstLine)-1 {
matched = true
break
}
}
if !matched {
return 0, false
}
return len(firstLine), true
}
func (p *EmbeddedContentParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
size, ok := p.Match(tokens)
if size == 0 || !ok {
return nil, errors.New("not matched")
}
return &ast.EmbeddedContent{
ResourceName: tokenizer.Stringify(tokens[3 : size-2]),
}, nil
}

@ -0,0 +1,51 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/ast"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
"github.com/usememos/memos/plugin/gomark/restore"
)
func TestEmbeddedContentParser(t *testing.T) {
tests := []struct {
text string
embeddedContent ast.Node
}{
{
text: "![[Hello world]",
embeddedContent: nil,
},
{
text: "![[Hello world]]",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "Hello world",
},
},
{
text: "![[memos/1]]",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "memos/1",
},
},
{
text: "![[resources/101]] \n123",
embeddedContent: nil,
},
{
text: "![[resources/101]]\n123",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "resources/101",
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
node, _ := NewEmbeddedContentParser().Parse(tokens)
require.Equal(t, restore.Restore([]ast.Node{test.embeddedContent}), restore.Restore([]ast.Node{node}))
}
}

@ -39,6 +39,7 @@ var defaultBlockParsers = []BlockParser{
NewUnorderedListParser(), NewUnorderedListParser(),
NewOrderedListParser(), NewOrderedListParser(),
NewMathBlockParser(), NewMathBlockParser(),
NewEmbeddedContentParser(),
NewParagraphParser(), NewParagraphParser(),
NewLineBreakParser(), NewLineBreakParser(),
} }

@ -218,6 +218,22 @@ func TestParser(t *testing.T) {
}, },
}, },
}, },
{
text: "Hello\n![[memos/101]]",
nodes: []ast.Node{
&ast.Paragraph{
Children: []ast.Node{
&ast.Text{
Content: "Hello",
},
},
},
&ast.LineBreak{},
&ast.EmbeddedContent{
ResourceName: "memos/101",
},
},
},
} }
for _, test := range tests { for _, test := range tests {

@ -39,9 +39,11 @@ func (*TableParser) Match(tokens []*tokenizer.Token) (int, bool) {
rowTokens := []*tokenizer.Token{} rowTokens := []*tokenizer.Token{}
for index, token := range tokens[len(headerTokens)+len(delimiterTokens)+2:] { for index, token := range tokens[len(headerTokens)+len(delimiterTokens)+2:] {
temp := len(headerTokens) + len(delimiterTokens) + 2 + index temp := len(headerTokens) + len(delimiterTokens) + 2 + index
if token.Type == tokenizer.Newline && temp != len(tokens)-1 && tokens[temp+1].Type != tokenizer.Pipe { if token.Type == tokenizer.Newline {
if (temp == len(tokens)-1) || (temp+1 == len(tokens)-1 && tokens[temp+1].Type == tokenizer.Newline) {
break break
} }
}
rowTokens = append(rowTokens, token) rowTokens = append(rowTokens, token)
} }
if len(rowTokens) < 5 { if len(rowTokens) < 5 {
@ -65,7 +67,18 @@ func (*TableParser) Match(tokens []*tokenizer.Token) (int, bool) {
if delimiterCells != headerCells || !ok { if delimiterCells != headerCells || !ok {
return 0, false return 0, false
} }
for _, t := range tokenizer.Split(delimiterTokens, tokenizer.Pipe) {
for index, t := range tokenizer.Split(delimiterTokens, tokenizer.Pipe) {
if index == 0 || index == headerCells {
if len(t) != 0 {
return 0, false
}
continue
}
if len(t) < 5 {
return 0, false
}
delimiterTokens := t[1 : len(t)-1] delimiterTokens := t[1 : len(t)-1]
if len(delimiterTokens) < 3 { if len(delimiterTokens) < 3 {
return 0, false return 0, false
@ -112,15 +125,16 @@ func (p *TableParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
delimiter := make([]string, 0) delimiter := make([]string, 0)
rows := make([][]string, 0) rows := make([][]string, 0)
for _, t := range tokenizer.Split(headerTokens, tokenizer.Pipe) { cols := len(tokenizer.Split(headerTokens, tokenizer.Pipe)) - 2
for _, t := range tokenizer.Split(headerTokens, tokenizer.Pipe)[1 : cols+1] {
header = append(header, tokenizer.Stringify(t[1:len(t)-1])) header = append(header, tokenizer.Stringify(t[1:len(t)-1]))
} }
for _, t := range tokenizer.Split(dilimiterTokens, tokenizer.Pipe) { for _, t := range tokenizer.Split(dilimiterTokens, tokenizer.Pipe)[1 : cols+1] {
delimiter = append(delimiter, tokenizer.Stringify(t[1:len(t)-1])) delimiter = append(delimiter, tokenizer.Stringify(t[1:len(t)-1]))
} }
for _, row := range rowTokens { for _, row := range rowTokens {
cells := make([]string, 0) cells := make([]string, 0)
for _, t := range tokenizer.Split(row, tokenizer.Pipe) { for _, t := range tokenizer.Split(row, tokenizer.Pipe)[1 : cols+1] {
cells = append(cells, tokenizer.Stringify(t[1:len(t)-1])) cells = append(cells, tokenizer.Stringify(t[1:len(t)-1]))
} }
rows = append(rows, cells) rows = append(rows, cells)
@ -145,10 +159,13 @@ func matchTableCellTokens(tokens []*tokenizer.Token) (int, bool) {
} }
} }
cells := tokenizer.Split(tokens, tokenizer.Pipe) cells := tokenizer.Split(tokens, tokenizer.Pipe)
if len(cells) != pipes-1 { if len(cells) != pipes+1 {
return 0, false
}
if len(cells[0]) != 0 || len(cells[len(cells)-1]) != 0 {
return 0, false return 0, false
} }
for _, cellTokens := range cells { for _, cellTokens := range cells[1 : len(cells)-1] {
if len(cellTokens) == 0 { if len(cellTokens) == 0 {
return 0, false return 0, false
} }
@ -160,5 +177,5 @@ func matchTableCellTokens(tokens []*tokenizer.Token) (int, bool) {
} }
} }
return len(cells), true return len(cells) - 1, true
} }

@ -132,20 +132,20 @@ func Stringify(tokens []*Token) string {
} }
func Split(tokens []*Token, delimiter TokenType) [][]*Token { func Split(tokens []*Token, delimiter TokenType) [][]*Token {
if len(tokens) == 0 {
return [][]*Token{}
}
result := make([][]*Token, 0) result := make([][]*Token, 0)
current := make([]*Token, 0) current := make([]*Token, 0)
for _, token := range tokens { for _, token := range tokens {
if token.Type == delimiter { if token.Type == delimiter {
if len(current) > 0 {
result = append(result, current) result = append(result, current)
current = make([]*Token, 0) current = make([]*Token, 0)
}
} else { } else {
current = append(current, token) current = append(current, token)
} }
} }
if len(current) > 0 {
result = append(result, current) result = append(result, current)
}
return result return result
} }

@ -77,3 +77,144 @@ func TestTokenize(t *testing.T) {
require.Equal(t, test.tokens, result) require.Equal(t, test.tokens, result)
} }
} }
func TestSplit(t *testing.T) {
tests := []struct {
tokens []*Token
sep TokenType
result [][]*Token
}{
{
tokens: []*Token{
{
Type: Asterisk,
Value: "*",
},
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
sep: Asterisk,
result: [][]*Token{
{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
},
},
{
tokens: []*Token{
{
Type: Asterisk,
Value: "*",
},
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
sep: Text,
result: [][]*Token{
{
{
Type: Asterisk,
Value: "*",
},
},
{
{
Type: Space,
Value: " ",
},
},
{
{
Type: ExclamationMark,
Value: "!",
},
},
},
},
{
tokens: []*Token{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: Newline,
Value: "\n",
},
},
sep: Newline,
result: [][]*Token{
{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
},
},
},
}
for _, test := range tests {
result := Split(test.tokens, test.sep)
require.Equal(t, test.result, result)
}
}

@ -36,21 +36,22 @@ enum NodeType {
TASK_LIST = 9; TASK_LIST = 9;
MATH_BLOCK = 10; MATH_BLOCK = 10;
TABLE = 11; TABLE = 11;
TEXT = 12; EMBEDDED_CONTENT = 12;
BOLD = 13; TEXT = 13;
ITALIC = 14; BOLD = 14;
BOLD_ITALIC = 15; ITALIC = 15;
CODE = 16; BOLD_ITALIC = 16;
IMAGE = 17; CODE = 17;
LINK = 18; IMAGE = 18;
AUTO_LINK = 19; LINK = 19;
TAG = 20; AUTO_LINK = 20;
STRIKETHROUGH = 21; TAG = 21;
ESCAPING_CHARACTER = 22; STRIKETHROUGH = 22;
MATH = 23; ESCAPING_CHARACTER = 23;
HIGHLIGHT = 24; MATH = 24;
SUBSCRIPT = 25; HIGHLIGHT = 25;
SUPERSCRIPT = 26; SUBSCRIPT = 26;
SUPERSCRIPT = 27;
} }
message Node { message Node {
@ -67,21 +68,22 @@ message Node {
TaskListNode task_list_node = 10; TaskListNode task_list_node = 10;
MathBlockNode math_block_node = 11; MathBlockNode math_block_node = 11;
TableNode table_node = 12; TableNode table_node = 12;
TextNode text_node = 13; EmbeddedContentNode embedded_content_node = 13;
BoldNode bold_node = 14; TextNode text_node = 14;
ItalicNode italic_node = 15; BoldNode bold_node = 15;
BoldItalicNode bold_italic_node = 16; ItalicNode italic_node = 16;
CodeNode code_node = 17; BoldItalicNode bold_italic_node = 17;
ImageNode image_node = 18; CodeNode code_node = 18;
LinkNode link_node = 19; ImageNode image_node = 19;
AutoLinkNode auto_link_node = 20; LinkNode link_node = 20;
TagNode tag_node = 21; AutoLinkNode auto_link_node = 21;
StrikethroughNode strikethrough_node = 22; TagNode tag_node = 22;
EscapingCharacterNode escaping_character_node = 23; StrikethroughNode strikethrough_node = 23;
MathNode math_node = 24; EscapingCharacterNode escaping_character_node = 24;
HighlightNode highlight_node = 25; MathNode math_node = 25;
SubscriptNode subscript_node = 26; HighlightNode highlight_node = 26;
SuperscriptNode superscript_node = 27; SubscriptNode subscript_node = 27;
SuperscriptNode superscript_node = 28;
} }
} }
@ -142,6 +144,10 @@ message TableNode {
repeated Row rows = 3; repeated Row rows = 3;
} }
message EmbeddedContentNode {
string resource_name = 1;
}
message TextNode { message TextNode {
string content = 1; string content = 1;
} }

@ -72,6 +72,7 @@
- [BoldNode](#memos-api-v2-BoldNode) - [BoldNode](#memos-api-v2-BoldNode)
- [CodeBlockNode](#memos-api-v2-CodeBlockNode) - [CodeBlockNode](#memos-api-v2-CodeBlockNode)
- [CodeNode](#memos-api-v2-CodeNode) - [CodeNode](#memos-api-v2-CodeNode)
- [EmbeddedContentNode](#memos-api-v2-EmbeddedContentNode)
- [EscapingCharacterNode](#memos-api-v2-EscapingCharacterNode) - [EscapingCharacterNode](#memos-api-v2-EscapingCharacterNode)
- [HeadingNode](#memos-api-v2-HeadingNode) - [HeadingNode](#memos-api-v2-HeadingNode)
- [HighlightNode](#memos-api-v2-HighlightNode) - [HighlightNode](#memos-api-v2-HighlightNode)
@ -1058,6 +1059,21 @@
<a name="memos-api-v2-EmbeddedContentNode"></a>
### EmbeddedContentNode
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| resource_name | [string](#string) | | |
<a name="memos-api-v2-EscapingCharacterNode"></a> <a name="memos-api-v2-EscapingCharacterNode"></a>
### EscapingCharacterNode ### EscapingCharacterNode
@ -1227,6 +1243,7 @@
| task_list_node | [TaskListNode](#memos-api-v2-TaskListNode) | | | | task_list_node | [TaskListNode](#memos-api-v2-TaskListNode) | | |
| math_block_node | [MathBlockNode](#memos-api-v2-MathBlockNode) | | | | math_block_node | [MathBlockNode](#memos-api-v2-MathBlockNode) | | |
| table_node | [TableNode](#memos-api-v2-TableNode) | | | | table_node | [TableNode](#memos-api-v2-TableNode) | | |
| embedded_content_node | [EmbeddedContentNode](#memos-api-v2-EmbeddedContentNode) | | |
| text_node | [TextNode](#memos-api-v2-TextNode) | | | | text_node | [TextNode](#memos-api-v2-TextNode) | | |
| bold_node | [BoldNode](#memos-api-v2-BoldNode) | | | | bold_node | [BoldNode](#memos-api-v2-BoldNode) | | |
| italic_node | [ItalicNode](#memos-api-v2-ItalicNode) | | | | italic_node | [ItalicNode](#memos-api-v2-ItalicNode) | | |
@ -1473,21 +1490,22 @@
| TASK_LIST | 9 | | | TASK_LIST | 9 | |
| MATH_BLOCK | 10 | | | MATH_BLOCK | 10 | |
| TABLE | 11 | | | TABLE | 11 | |
| TEXT | 12 | | | EMBEDDED_CONTENT | 12 | |
| BOLD | 13 | | | TEXT | 13 | |
| ITALIC | 14 | | | BOLD | 14 | |
| BOLD_ITALIC | 15 | | | ITALIC | 15 | |
| CODE | 16 | | | BOLD_ITALIC | 16 | |
| IMAGE | 17 | | | CODE | 17 | |
| LINK | 18 | | | IMAGE | 18 | |
| AUTO_LINK | 19 | | | LINK | 19 | |
| TAG | 20 | | | AUTO_LINK | 20 | |
| STRIKETHROUGH | 21 | | | TAG | 21 | |
| ESCAPING_CHARACTER | 22 | | | STRIKETHROUGH | 22 | |
| MATH | 23 | | | ESCAPING_CHARACTER | 23 | |
| HIGHLIGHT | 24 | | | MATH | 24 | |
| SUBSCRIPT | 25 | | | HIGHLIGHT | 25 | |
| SUPERSCRIPT | 26 | | | SUBSCRIPT | 26 | |
| SUPERSCRIPT | 27 | |

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
import { useContext, useEffect } from "react";
import { useMemoStore } from "@/store/v1";
import MemoContent from "..";
import { RendererContext } from "../types";
interface Props {
memoId: number;
}
const EmbeddedMemo = ({ memoId }: Props) => {
const context = useContext(RendererContext);
const memoStore = useMemoStore();
const memo = memoStore.getMemoById(memoId);
const resourceName = `memos/${memoId}`;
useEffect(() => {
memoStore.getOrFetchMemoById(memoId);
}, [memoId]);
if (memoId === context.memoId || context.embeddedMemos.has(resourceName)) {
return <p>Nested Rendering Error: {`![[${resourceName}]]`}</p>;
}
context.embeddedMemos.add(resourceName);
return (
<div className="embedded-memo">
<MemoContent nodes={memo.nodes} memoId={memoId} embeddedMemos={context.embeddedMemos} />
</div>
);
};
export default EmbeddedMemo;

@ -0,0 +1,20 @@
import EmbeddedMemo from "./EmbeddedMemo";
interface Props {
resourceName: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const EmbeddedContent = ({ resourceName }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <EmbeddedMemo memoId={Number(resourceId)} />;
}
return <p>Unknown resource: {resourceName}</p>;
};
export default EmbeddedContent;

@ -5,6 +5,7 @@ import {
BoldNode, BoldNode,
CodeBlockNode, CodeBlockNode,
CodeNode, CodeNode,
EmbeddedContentNode,
EscapingCharacterNode, EscapingCharacterNode,
HeadingNode, HeadingNode,
HighlightNode, HighlightNode,
@ -31,6 +32,7 @@ import Bold from "./Bold";
import BoldItalic from "./BoldItalic"; import BoldItalic from "./BoldItalic";
import Code from "./Code"; import Code from "./Code";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import EmbeddedContent from "./EmbeddedContent";
import EscapingCharacter from "./EscapingCharacter"; import EscapingCharacter from "./EscapingCharacter";
import Heading from "./Heading"; import Heading from "./Heading";
import Highlight from "./Highlight"; import Highlight from "./Highlight";
@ -80,6 +82,8 @@ const Renderer: React.FC<Props> = ({ index, node }: Props) => {
return <Math {...(node.mathBlockNode as MathNode)} block={true} />; return <Math {...(node.mathBlockNode as MathNode)} block={true} />;
case NodeType.TABLE: case NodeType.TABLE:
return <Table {...(node.tableNode as TableNode)} />; return <Table {...(node.tableNode as TableNode)} />;
case NodeType.EMBEDDED_CONTENT:
return <EmbeddedContent {...(node.embeddedContentNode as EmbeddedContentNode)} />;
case NodeType.TEXT: case NodeType.TEXT:
return <Text {...(node.textNode as TextNode)} />; return <Text {...(node.textNode as TextNode)} />;
case NodeType.BOLD: case NodeType.BOLD:

@ -10,12 +10,15 @@ interface Props {
memoId?: number; memoId?: number;
readonly?: boolean; readonly?: boolean;
disableFilter?: boolean; disableFilter?: boolean;
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos?: Set<string>;
className?: string; className?: string;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
} }
const MemoContent: React.FC<Props> = (props: Props) => { const MemoContent: React.FC<Props> = (props: Props) => {
const { className, memoId, nodes, onClick } = props; const { className, memoId, nodes, embeddedMemos, onClick } = props;
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const memoContentContainerRef = useRef<HTMLDivElement>(null); const memoContentContainerRef = useRef<HTMLDivElement>(null);
@ -37,6 +40,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
memoId, memoId,
readonly: !allowEdit, readonly: !allowEdit,
disableFilter: props.disableFilter, disableFilter: props.disableFilter,
embeddedMemos: embeddedMemos || new Set(),
}} }}
> >
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}> <div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}>

@ -3,6 +3,9 @@ import { Node } from "@/types/proto/api/v2/markdown_service";
interface Context { interface Context {
nodes: Node[]; nodes: Node[];
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos: Set<string>;
memoId?: number; memoId?: number;
readonly?: boolean; readonly?: boolean;
disableFilter?: boolean; disableFilter?: boolean;
@ -10,4 +13,5 @@ interface Context {
export const RendererContext = createContext<Context>({ export const RendererContext = createContext<Context>({
nodes: [], nodes: [],
embeddedMemos: new Set(),
}); });

Loading…
Cancel
Save