Skip to content

Commit

Permalink
fix: add support for task lists in Markdown rendering add check or an…
Browse files Browse the repository at this point in the history
…d cancel all check

- Added `remark-task-list` plugin to enhance Markdown rendering capabilities.
- Updated `MarkdownRender` component to handle task list items, allowing for interactive checkboxes.
- Modified `ListItem` component to manage task list states and toggle completion.
- Adjusted CSS to ensure proper styling for ordered lists in Markdown.

This update improves the user experience by enabling task management directly within Markdown content.
  • Loading branch information
blinko-space committed Dec 13, 2024
1 parent 95352e3 commit d2e3271
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 81 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-task-list": "^0.0.3",
"sharp": "^0.33.5",
"sqlite3": "^5.1.7",
"superjson": "^2.2.1",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

194 changes: 121 additions & 73 deletions src/components/Common/MarkdownRender/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,97 +2,145 @@ import { Icon } from '@iconify/react';
import React from 'react';

interface ListItemProps {
children: any;
children: React.ReactNode;
content: string;
onChange?: (newContent: string) => void;
className?: string;
}

const renderListItems = (children: any, onChange: (newContent: string) => void, content: string) => {
const childrenArray = Array.isArray(children) ? children : [children];
export const ListItem: React.FC<ListItemProps> = ({ children, content, onChange, className }) => {
if (!className?.includes('task-list-item')) {
return <li className={className}>{children}</li>;
}

return childrenArray?.map((child: any, index: number) => {
if (child?.type === 'input') {
let text = '';
let originalText = '';
let renderItem: React.ReactElement[] = [];
const siblings = children.filter((_, i) => i !== index);
const childArray = React.Children.toArray(children);
const checkbox = childArray.find((child: any) => child?.type === 'input') as any;
const isChecked = checkbox?.props?.checked ?? false;

const textContent = siblings.map(sibling => {
if (typeof sibling === 'string') {
return sibling;
}
if (sibling?.props?.children) {
return sibling.props.children;
const getTaskText = (nodes: any[]): string => {
return nodes
.filter((node: any) => node?.type !== 'input')
.map((node: any) => {
if (typeof node === 'string') return node;
if (node?.props?.href) return `[${node.props.children}](${node.props.href})`;
if (node?.props?.children) {
return Array.isArray(node.props.children)
? getTaskText(node.props.children)
: node.props.children;
}
return '';
}).join('');
})
.join('');
};

const originalTextContent = siblings.map(sibling => {
if (typeof sibling === 'string') {
return sibling;
}
if (sibling?.props?.href) {
return `[${sibling.props.children}](${sibling.props.href})`;
const getChildTasks = (nodes: any[]): { text: string; checked: boolean }[] => {
//@ts-ignore
return nodes
.filter((node: any) => node?.type !== 'input')
.map((node: any) => {
if (node?.props?.className?.includes('task-list-item')) {
const childCheckbox = node.props.children.find((child: any) => child?.type === 'input');
const text = getTaskText([node]);
console.log('Found child task:', { text, checked: childCheckbox?.props?.checked });
return { text, checked: childCheckbox?.props?.checked ?? false };
}
if (sibling?.props?.children) {
return sibling.props.children;
if (node?.props?.children) {
return getChildTasks(Array.isArray(node.props.children) ? node.props.children : [node.props.children]).flat();
}
return '';
}).join('');
return null;
})
.filter(Boolean)
.flat();
};

text = textContent;
originalText = originalTextContent;
const taskText = getTaskText(childArray);
const textContent = childArray.filter((child: any) => child?.type !== 'input');
const childTasks = getChildTasks(childArray);
const hasChildren = childTasks.length > 0;
const allChildrenChecked = hasChildren && childTasks.every(task => task.checked);
const someChildrenChecked = hasChildren && childTasks.some(task => task.checked);

renderItem.push(
<span key={index}>
{siblings.map((sibling, i) =>
typeof sibling === 'string' ? sibling : React.cloneElement(sibling, { key: `sibling-${i}` })
)}
</span>
);
// console.log('Task info:', {
// text: taskText,
// hasChildren,
// childTasks,
// allChildrenChecked,
// someChildrenChecked
// });

const isChecked = child.props?.checked ?? false;
const iconType = isChecked ? "lets-icons:check-fill" : "ci:radio-unchecked";
const handleToggle = (e: React.MouseEvent) => {
// console.log('Toggle clicked');
if (!onChange) return;
e.stopPropagation();

let newContent = content;
const targetState = hasChildren ? !allChildrenChecked : !isChecked;

return (
<div key={index} className='!ml-[-30px] flex items-start gap-1 cursor-pointer hover:opacity-80'
onClick={() => {
const newContent = isChecked
? content.replace(`* [x]${originalText}`, `* [ ]${originalText}`).replace(`- [x]${originalText}`, `- [ ]${originalText}`)
: content.replace(`* [ ]${originalText}`, `* [x]${originalText}`).replace(`- [ ]${originalText}`, `- [x]${originalText}`);
onChange(newContent)
}}>
<div className='w-[20px] h-[20px] flex-shrink-0 mt-[3px]'>
<Icon className='text-[#EAB308]' icon={iconType} width="20" height="20" />
</div>
<div className={`${isChecked ? 'line-through text-desc' : ''} break-all`}>{renderItem}</div>
</div>
);
}
if (child?.props?.className === 'task-list-item') {
return renderListItems(child.props.children, onChange, content);
// console.log('Updating states:', {
// current: isChecked,
// target: targetState,
// hasChildren,
// allChildrenChecked
// });

const oldMark = `* [${isChecked ? 'x' : ' '}]${taskText}`;
const newMark = `* [${targetState ? 'x' : ' '}]${taskText}`;
newContent = newContent.replace(oldMark, newMark);

if (hasChildren) {
childTasks.forEach(task => {
const oldChildMark = `* [${task.checked ? 'x' : ' '}]${task.text}`;
const newChildMark = `* [${targetState ? 'x' : ' '}]${task.text}`;
// console.log('Updating child task:', {
// from: oldChildMark,
// to: newChildMark
// });
newContent = newContent.replace(oldChildMark, newChildMark);
});
}
if (child?.type === 'ul') {
return (
<ul key={index}>
{renderListItems(child.props.children, onChange, content)}
</ul>
);

// console.log('Content update:', {
// old: content,
// new: newContent
// });

onChange(newContent);
};

const getIcon = () => {
if (!hasChildren) {
return isChecked ? "lets-icons:check-fill" : "ci:radio-unchecked";
}
if (typeof child === 'string') {
if (allChildrenChecked) {
return "lets-icons:check-fill";
}
if (child?.props?.children) {
return renderListItems(child.props.children, onChange, content);
if (someChildrenChecked) {
return "ri:indeterminate-circle-line";
}
}).filter(Boolean);
};
return "ci:radio-unchecked";
};

export const ListItem = ({ children, content, onChange }: ListItemProps) => {
try {
//@ts-ignore
return <ul className="break-all">{renderListItems(children, onChange, content)}</ul>;
} catch (error) {
console.log(error)
return <li className="break-all">{children}</li>;
}
return (
<li className={`${className} !list-none`}>
<div
className='flex items-start gap-1 -ml-[15px] cursor-pointer'
onClick={handleToggle}
>
<div className='w-[20px] h-[20px] flex-shrink-0 mt-[3px] hover:opacity-80 transition-all'>
<Icon
className='text-[#EAB308]'
icon={getIcon()}
width="20"
height="20"
/>
</div>
<div
className={`${isChecked ? 'line-through text-desc' : ''} break-all flex-1 min-w-0`}
onClick={e => e.stopPropagation()}
>
{textContent}
</div>
</div>
</li>
);
};
18 changes: 17 additions & 1 deletion src/components/Common/MarkdownRender/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import dynamic from 'next/dynamic';
import { Skeleton } from '@nextui-org/react';
import { TableWrapper } from './TableWrapper';
import { useRouter } from 'next/router';
import remarkTaskList from 'remark-task-list';

const MermaidWrapper = dynamic(() => import('./MermaidWrapper').then(mod => mod.MermaidWrapper), {
loading: () => <Skeleton className='w-full h-[40px]' />,
Expand Down Expand Up @@ -82,6 +83,7 @@ export const MarkdownRender = observer(({ content = '', onChange, isShareMode }:
<ReactMarkdown
remarkPlugins={[
[remarkGfm, { table: false }],
remarkTaskList,
[remarkMath, {
singleDollarTextMath: true,
inlineMath: [['$', '$']],
Expand Down Expand Up @@ -120,7 +122,21 @@ export const MarkdownRender = observer(({ content = '', onChange, isShareMode }:
a: ({ node, children }) => {
return <LinkPreview href={node?.properties?.href} text={children} />
},
li: ({ node, children }) => <ListItem content={content} onChange={onChange}>{children}</ListItem>,
li: ({ node, children, className }) => {
const isTaskListItem = className?.includes('task-list-item');
if (isTaskListItem && onChange && !isShareMode) {
return (
<ListItem
content={content}
onChange={onChange}
className={className}
>
{children}
</ListItem>
);
}
return <li className={className}>{children}</li>;
},
img: ImageWrapper,
table: TableWrapper
}}
Expand Down
14 changes: 7 additions & 7 deletions src/styles/github-markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -734,9 +734,7 @@ ol {
list-style-type: decimal;
}

.markdown-body div>ol:not([type]) {
list-style-type: decimal !important;
}


.markdown-body ul ul,
.markdown-body ul ol,
Expand Down Expand Up @@ -1348,11 +1346,13 @@ table tr:last-child td:last-child {
margin-top: 0 !important;
}

ol {
list-style-type: decimal !important;
}





.markdown-body ol ol, .markdown-body ol {
list-style-type: decimal !important;
}



Expand Down

0 comments on commit d2e3271

Please sign in to comment.