Skip to content
Draft
2 changes: 1 addition & 1 deletion package-lock.json

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

55 changes: 41 additions & 14 deletions src/components/markdown/examples/markdown-composite.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { LimelInputFieldCustomEvent } from '@limetech/lime-elements';
import {
LimelInputFieldCustomEvent,
LimelMarkdownCustomEvent,
} from '@limetech/lime-elements';
import { Component, State, h } from '@stencil/core';

/**
Expand All @@ -12,26 +15,50 @@ import { Component, State, h } from '@stencil/core';
})
export class MarkdownRenderContentExample {
@State()
private markdown = '# Hello, world!\n\nThis is **markdown**!';
private markdown = `# Hello, world!

This is **markdown**!

- [x] test
- [x] test

## Task Lists

- [ ] This is an unchecked task
- [x] This is a completed task
- [ ] Nested unchecked task
- [x] Nested completed task
- [ ] Another unchecked task`;

public render() {
return [
<limel-input-field
label="Markdown to render"
type="textarea"
value={this.markdown}
onChange={this.handleMarkdownChange}
/>,
<fieldset>
<legend>Rendered markdown</legend>
<limel-markdown value={this.markdown} />
</fieldset>,
];
return (
<div>
<limel-input-field
label="Markdown to render"
type="textarea"
value={this.markdown}
onChange={this.handleMarkdownChange}
/>
<fieldset>
<legend>Rendered markdown</legend>
<limel-markdown
value={this.markdown}
onTaskListChange={this.handleTaskListChange}
/>
</fieldset>
</div>
);
}

private handleMarkdownChange = (
event: LimelInputFieldCustomEvent<string>
) => {
this.markdown = event.detail;
};

private handleTaskListChange = (
event: LimelMarkdownCustomEvent<string>
) => {
this.markdown = event.detail;
};
}
46 changes: 46 additions & 0 deletions src/components/markdown/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ export async function markdownToHTML(
})
.use(() => {
return (tree: Node) => {
// Make task list checkboxes interactive by removing the disabled attribute
// that remark-gfm adds by default
visit(tree, 'element', (node: any) => {
if (
node.tagName === 'input' &&
node.properties?.type === 'checkbox' &&
node.properties?.disabled !== undefined
) {
// Check if this checkbox is inside a task list item
// We can identify this by looking for the task-list-item class in parent
delete node.properties.disabled;
}
});

// Run the sanitizeStyle function on all elements, to sanitize
// the value of the `style` attribute, if there is one.
visit(tree, 'element', sanitizeStyle);
Expand Down Expand Up @@ -99,6 +113,8 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames || []),
'input', // Explicitly allow input elements for task list checkboxes
'limel-checkbox', // Allow limel-checkbox component for task lists
...allowedComponents.map((component) => component.tagName),
],
attributes: {
Expand All @@ -108,6 +124,36 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema {
['className', 'MsoNormal'],
], // Allow the class 'MsoNormal' on <p> elements
a: [...(defaultSchema.attributes.a ?? []), 'referrerpolicy'], // Allow referrerpolicy on <a> elements
// Allow task list specific classes and attributes
ul: [
...(defaultSchema.attributes.ul ?? []),
['className', 'task-list'],
['className', 'contains-task-list'], // Allow remark-gfm generated class
],
li: [
...(defaultSchema.attributes.li ?? []),
['className', 'task-list-item'],
],
div: [
...(defaultSchema.attributes.div ?? []),
['className', 'task-list-item-content'],
],
input: [
...(defaultSchema.attributes.input ?? []),
'type',
'checked',
'disabled',
],
// Allow limel-checkbox attributes
'limel-checkbox': [
'checked',
'disabled',
'readonly',
'invalid',
'required',
'indeterminate',
['className'],
],
'*': asteriskAttributeWhitelist,
},
};
Expand Down
62 changes: 55 additions & 7 deletions src/components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, h, Prop, Watch } from '@stencil/core';
import { Component, h, Prop, Watch, Event, EventEmitter } from '@stencil/core';
import { markdownToHTML } from './markdown-parser';
import { globalConfig } from '../../global/config';
import { CustomElementDefinition } from '../../global/shared-types/custom-element.types';
Expand Down Expand Up @@ -55,6 +55,13 @@ export class Markdown {
@Prop()
public lazyLoadImages = false;

/**
* Emitted when a task list checkbox is clicked.
* The event detail contains the updated markdown text.
*/
@Event()
public taskListChange: EventEmitter<string>;

@Watch('value')
public async textChanged() {
try {
Expand All @@ -69,6 +76,7 @@ export class Markdown {
this.rootElement.innerHTML = html;

this.setupImageIntersectionObserver();
this.setupTaskListHandlers();
} catch (error) {
console.error(error);
}
Expand All @@ -86,12 +94,52 @@ export class Markdown {
}

public render() {
return [
<div
id="markdown"
ref={(el) => (this.rootElement = el as HTMLDivElement)}
/>,
];
return <div id="markdown" ref={(el) => (this.rootElement = el)} />;
}

private setupTaskListHandlers() {
// Make task list checkboxes interactive and sync back to markdown
const checkboxes = this.rootElement.querySelectorAll(
'.task-list-item input[type="checkbox"]'
);

// Parse the current markdown to find task list items
const lines = this.value.split('\n');
let taskListIndex = 0;

for (const checkbox of checkboxes) {
const inputElement = checkbox as HTMLInputElement;
const currentTaskIndex = taskListIndex++;

inputElement.addEventListener('change', () => {
// Find the corresponding line in the markdown
let taskCounter = 0;

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match both checked and unchecked task list items
// Using a more specific regex to avoid backtracking issues
const taskListRegex = /^(\s*)- \[([x ])\] (.+)$/;
const taskListMatch = taskListRegex.exec(line);

if (taskListMatch) {
if (taskCounter === currentTaskIndex) {
// Update this line
const indent = taskListMatch[1];
const newState = inputElement.checked ? 'x' : ' ';
const text = taskListMatch[3];
lines[i] = `${indent}- [${newState}] ${text}`;

// Emit the updated markdown
const updatedMarkdown = lines.join('\n');
this.taskListChange.emit(updatedMarkdown);
break;
}
taskCounter++;
}
}
});
}
}

private setupImageIntersectionObserver() {
Expand Down
84 changes: 82 additions & 2 deletions src/components/markdown/partial-styles/_lists.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
ul {
list-style: none;
margin-top: 0.25rem;
padding-left: 0;

li {
position: relative;
margin-left: 0.75rem;
margin-bottom: 0.25rem;

&:before {
content: '';
Expand All @@ -17,6 +21,15 @@ ul {
background-color: rgb(var(--contrast-700));
display: block;
}

// Task list items should not have bullet points
&.task-list-item {
margin-left: 0;

&:before {
display: none;
}
}
}
}

Expand All @@ -25,9 +38,76 @@ ol {
padding-left: 1rem;
}

ul {
margin-top: 0.25rem;
// Task list specific styles
.task-list,
.contains-task-list {
list-style: none;
padding-left: 0;

.task-list-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.25rem;
margin-left: 0;

&:before {
display: none; // Remove bullet points
}

input[type='checkbox'] {
margin: 0;
margin-top: 0.125rem; // Align with first line of text
flex-shrink: 0;
width: 1rem;
height: 1rem;
cursor: pointer;

// Ensure checkbox is interactive
pointer-events: auto;

// Style to match other form elements
border: 1px solid rgb(var(--contrast-600));
border-radius: 0.125rem;
background-color: rgb(var(--contrast-100));

&:checked {
background-color: rgb(var(--color-sky-default));
border-color: rgb(var(--color-sky-default));
}

&:hover {
border-color: rgb(var(--contrast-800));
}

&:focus {
outline: 2px solid rgb(var(--color-sky-light));
outline-offset: 1px;
}
}

// Handle both paragraph-wrapped and direct text content
p {
margin: 0;
flex: 1;
line-height: 1.5;
}

// Direct text content (when not wrapped in paragraphs)
> * {
&:not(input):not(ul):not(ol) {
flex: 1;
line-height: 1.5;
}
}

// Nested task lists
.task-list,
.contains-task-list {
margin-top: 0.25rem;
margin-left: 1.5rem;
}
}
}

ul ul,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Component, h, State } from '@stencil/core';
/**
* Task list example
*
* This example demonstrates the task list functionality in the text editor.
* You can create interactive checkbox lists that can be toggled and managed.
*/
@Component({
tag: 'limel-example-text-editor-with-task-lists',
shadow: true,
})
export class TextEditorTaskListExample {
@State()
private value: string = `# Task List Example

Here's an example with task lists:

- [ ] First unchecked task
- [x] This task is completed
- [ ] Another unchecked task
- [ ] Task with some **bold** text

You can click the checkbox button in the toolbar to create more task lists!

## Regular list for comparison

- Regular bullet point
- Another bullet point
- Third bullet point

## Keyboard shortcuts
- **Enter**: Create new list item
- **Tab**: Indent list item (nest deeper)
- **Shift+Tab**: Outdent list item (nest shallower)
`;

public render() {
return [
<limel-text-editor
key="task-list-editor"
value={this.value}
onChange={this.handleChange}
contentType="markdown"
/>,
<limel-example-value key="task-list-value" value={this.value} />,
];
}

private handleChange = (event: CustomEvent<string>) => {
this.value = event.detail;
};
}
Loading
Loading