diff --git a/package-lock.json b/package-lock.json index 96a6f3fa8a..0a6735c46c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23496,7 +23496,7 @@ "integrity": "sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==", "dev": true, "requires": { - "prosemirror-model": ">=1.22.1", + "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } diff --git a/src/components/markdown/examples/markdown-composite.tsx b/src/components/markdown/examples/markdown-composite.tsx index 8d5acdf61e..ed28f8872b 100644 --- a/src/components/markdown/examples/markdown-composite.tsx +++ b/src/components/markdown/examples/markdown-composite.tsx @@ -1,4 +1,7 @@ -import { LimelInputFieldCustomEvent } from '@limetech/lime-elements'; +import { + LimelInputFieldCustomEvent, + LimelMarkdownCustomEvent, +} from '@limetech/lime-elements'; import { Component, State, h } from '@stencil/core'; /** @@ -12,21 +15,39 @@ 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 [ - , -
- Rendered markdown - -
, - ]; + return ( +
+ +
+ Rendered markdown + +
+
+ ); } private handleMarkdownChange = ( @@ -34,4 +55,10 @@ export class MarkdownRenderContentExample { ) => { this.markdown = event.detail; }; + + private handleTaskListChange = ( + event: LimelMarkdownCustomEvent + ) => { + this.markdown = event.detail; + }; } diff --git a/src/components/markdown/markdown-parser.ts b/src/components/markdown/markdown-parser.ts index f8c4bd34e0..0763aa8391 100644 --- a/src/components/markdown/markdown-parser.ts +++ b/src/components/markdown/markdown-parser.ts @@ -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); @@ -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: { @@ -108,6 +124,36 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema { ['className', 'MsoNormal'], ], // Allow the class 'MsoNormal' on

elements a: [...(defaultSchema.attributes.a ?? []), 'referrerpolicy'], // Allow referrerpolicy on 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, }, }; diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 93b87f157e..5259d32123 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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'; @@ -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; + @Watch('value') public async textChanged() { try { @@ -69,6 +76,7 @@ export class Markdown { this.rootElement.innerHTML = html; this.setupImageIntersectionObserver(); + this.setupTaskListHandlers(); } catch (error) { console.error(error); } @@ -86,12 +94,52 @@ export class Markdown { } public render() { - return [ -

(this.rootElement = el as HTMLDivElement)} - />, - ]; + return
(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() { diff --git a/src/components/markdown/partial-styles/_lists.scss b/src/components/markdown/partial-styles/_lists.scss index 7be8767685..6e8853a88c 100644 --- a/src/components/markdown/partial-styles/_lists.scss +++ b/src/components/markdown/partial-styles/_lists.scss @@ -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: ''; @@ -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; + } + } } } @@ -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, diff --git a/src/components/text-editor/examples/text-editor-with-task-lists.tsx b/src/components/text-editor/examples/text-editor-with-task-lists.tsx new file mode 100644 index 0000000000..53ff7d9c9a --- /dev/null +++ b/src/components/text-editor/examples/text-editor-with-task-lists.tsx @@ -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 [ + , + , + ]; + } + + private handleChange = (event: CustomEvent) => { + this.value = event.detail; + }; +} diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts index 8c79a826c2..077e40292f 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts @@ -329,6 +329,7 @@ const commandMapping: CommandMapping = { createListCommand(schema, EditorMenuTypes.OrderedList), bullet_list: (schema) => createListCommand(schema, EditorMenuTypes.BulletList), + task_list: (schema) => createListCommand(schema, EditorMenuTypes.TaskList), }; export class MenuCommandFactory { diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts index 40377e5b3e..a6c8254a92 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts @@ -66,6 +66,13 @@ const textEditorMenuItems: Array< iconOnly: true, selected: false, }, + { + value: EditorMenuTypes.TaskList, + text: 'Task list', + icon: 'checklist', + iconOnly: true, + selected: false, + }, { separator: true }, { value: EditorMenuTypes.HeaderLevel1, @@ -129,6 +136,7 @@ export const menuTranslationIDs = { bullet_list: 'editor-menu.bulleted-list', ordered_list: 'editor-menu.numbered-list', + task_list: 'editor-menu.task-list', code_block: 'editor-menu.code-block', blockquote: 'editor-menu.blockquote', diff --git a/src/components/text-editor/prosemirror-adapter/menu/types.ts b/src/components/text-editor/prosemirror-adapter/menu/types.ts index 8de1093fd8..e5cd0c99ef 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/types.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/types.ts @@ -15,6 +15,7 @@ export const EditorMenuTypes = { Link: 'link', OrderedList: 'ordered_list', BulletList: 'bullet_list', + TaskList: 'task_list', Strikethrough: 'strikethrough', Code: 'code', CodeBlock: 'code_block', diff --git a/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts new file mode 100644 index 0000000000..95d7e160dd --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts @@ -0,0 +1,90 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { keymap } from 'prosemirror-keymap'; +import { Schema } from 'prosemirror-model'; +import { liftListItem, sinkListItem } from 'prosemirror-schema-list'; +import { + liftTaskListItem, + sinkTaskListItem, +} from './task-list/task-list-commands'; + +const listIndentationKey = new PluginKey('listIndentation'); + +export const createListIndentationPlugin = (schema: Schema): Plugin => { + const listItemType = schema.nodes.list_item; + const taskListItemType = schema.nodes.task_list_item; + + // Create the task list commands + const sinkTaskList = sinkTaskListItem(schema); + const liftTaskList = liftTaskListItem(schema); + + console.log('List indentation plugin initialized'); + console.log('listItemType:', listItemType); + console.log('taskListItemType:', taskListItemType); + + if (!listItemType && !taskListItemType) { + console.log('No list types found in schema'); + return new Plugin({ + key: listIndentationKey, + }); + } + + return keymap({ + Tab: (state, dispatch) => { + console.log('Tab key pressed in list indentation plugin'); + const { selection } = state; + const { $from } = selection; + + // Check if we're in any type of list item + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + console.log(`Tab - Depth ${d}:`, node.type.name, node); + + // Handle regular list items + if (listItemType && node.type === listItemType) { + console.log('Indenting regular list item'); + return sinkListItem(listItemType)(state, dispatch); + } + + // Handle task list items + if (taskListItemType && node.type === taskListItemType) { + console.log('Indenting task list item'); + return sinkTaskList(state, dispatch); + } + } + + console.log('Not in a list item, consuming Tab to keep focus'); + // Return true to prevent default browser Tab behavior (losing focus) + // but don't dispatch any changes since we're not in a list + return true; + }, + 'Shift-Tab': (state, dispatch) => { + console.log('Shift-Tab key pressed in list indentation plugin'); + const { selection } = state; + const { $from } = selection; + + // Check if we're in any type of list item + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + console.log(`Shift-Tab - Depth ${d}:`, node.type.name, node); + + // Handle regular list items + if (listItemType && node.type === listItemType) { + console.log('Outdenting regular list item'); + return liftListItem(listItemType)(state, dispatch); + } + + // Handle task list items + if (taskListItemType && node.type === taskListItemType) { + console.log('Outdenting task list item'); + return liftTaskList(state, dispatch); + } + } + + console.log( + 'Not in a list item, consuming Shift-Tab to keep focus' + ); + // Return true to prevent default browser Shift-Tab behavior + return true; + }, + }); +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts new file mode 100644 index 0000000000..39eff2c8c9 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts @@ -0,0 +1,72 @@ +import { Command } from 'prosemirror-state'; +import { + wrapInList, + liftListItem, + sinkListItem, +} from 'prosemirror-schema-list'; +import { Schema } from 'prosemirror-model'; +import { CommandWithActive } from '../../menu/menu-commands'; + +export const createTaskList = (schema: Schema): CommandWithActive => { + const taskListType = schema.nodes.task_list; + const taskListItemType = schema.nodes.task_list_item; + + if (!taskListType || !taskListItemType) { + return () => false; + } + + const command: Command = wrapInList(taskListType, { checked: false }); + + // Add active state detection + const commandWithActive = command as CommandWithActive; + commandWithActive.active = (state) => { + const { $from } = state.selection; + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (node && node.type === taskListType) { + return true; + } + } + return false; + }; + + return commandWithActive; +}; + +export const toggleTaskListItem = (schema: Schema): Command => { + const taskListItemType = schema.nodes.task_list_item; + + return (state, dispatch) => { + const { from } = state.selection; + const $from = state.doc.resolve(from); + + // Find the task list item + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (node && node.type === taskListItemType) { + const pos = $from.before(d); + const checked = !node.attrs.checked; + + if (dispatch) { + const tr = state.tr.setNodeMarkup(pos, null, { + ...node.attrs, + checked, + }); + dispatch(tr); + } + return true; + } + } + return false; + }; +}; + +export const liftTaskListItem = (schema: Schema): Command => { + const taskListItemType = schema.nodes.task_list_item; + return liftListItem(taskListItemType); +}; + +export const sinkTaskListItem = (schema: Schema): Command => { + const taskListItemType = schema.nodes.task_list_item; + return sinkListItem(taskListItemType); +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts new file mode 100644 index 0000000000..382a875643 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts @@ -0,0 +1,70 @@ +import { NodeView } from 'prosemirror-view'; +import { Node as ProseMirrorNode } from 'prosemirror-model'; + +export class TaskListItemView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + checkbox: HTMLInputElement; + private getPos: () => number; + private view: any; + + constructor(node: ProseMirrorNode, view: any, getPos: () => number) { + this.getPos = getPos; + this.view = view; + + // Create the list item element + this.dom = document.createElement('li'); + this.dom.className = 'task-list-item'; + + // Create the checkbox + this.checkbox = document.createElement('input'); + this.checkbox.type = 'checkbox'; + this.checkbox.checked = node.attrs.checked; + + // Create content container + this.contentDOM = document.createElement('div'); + this.contentDOM.className = 'task-list-item-content'; + + // Assemble the DOM + this.dom.append(this.checkbox); + this.dom.append(this.contentDOM); + + // Add click handler for checkbox + this.checkbox.addEventListener('click', this.handleCheckboxClick); + } + + private handleCheckboxClick = (event: Event) => { + // Don't prevent default - let the checkbox toggle naturally + const newChecked = (event.target as HTMLInputElement).checked; + const pos = this.getPos(); + const tr = this.view.state.tr.setNodeMarkup(pos, null, { + checked: newChecked, + }); + this.view.dispatch(tr); + }; + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== 'task_list_item') { + return false; + } + this.checkbox.checked = node.attrs.checked; + return true; + } + + stopEvent(event: Event): boolean { + // Allow checkbox clicks to be handled by our custom handler + return event.target === this.checkbox; + } + + destroy(): void { + this.checkbox.removeEventListener('click', this.handleCheckboxClick); + } +} + +export const createTaskListItemNodeView = ( + node: ProseMirrorNode, + view: any, + getPos: () => number +) => { + return new TaskListItemView(node, view, getPos); +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts new file mode 100644 index 0000000000..616741360c --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts @@ -0,0 +1,46 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { keymap } from 'prosemirror-keymap'; +import { Schema } from 'prosemirror-model'; +import { splitListItem } from 'prosemirror-schema-list'; + +const taskListKey = new PluginKey('taskList'); + +export const createTaskListPlugin = (schema: Schema): Plugin => { + const taskListItemType = schema.nodes.task_list_item; + const listItemType = schema.nodes.list_item; // Also check regular list items + + console.log('Task list plugin initialized'); + console.log('taskListItemType:', taskListItemType); + console.log('listItemType:', listItemType); + + if (!taskListItemType) { + console.log('No task_list_item type found in schema'); + return new Plugin({ + key: taskListKey, + }); + } + + return keymap({ + Enter: (state, dispatch) => { + console.log('Enter key pressed in task list plugin'); + + // Debug: check what type of node we're in + const { selection } = state; + const { $from } = selection; + + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + console.log(`Depth ${d}:`, node.type.name, node.attrs); + + if (node.type === taskListItemType) { + console.log('Found task_list_item, using splitListItem'); + return splitListItem(taskListItemType)(state, dispatch); + } + } + + console.log('Not in a task_list_item, allowing other handlers'); + return false; + }, + // Removed Mod-[ and Mod-] - now handled by list indentation plugin + }); +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts new file mode 100644 index 0000000000..f8fea6a49b --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts @@ -0,0 +1,69 @@ +import { NodeSpec } from 'prosemirror-model'; + +export const taskListNodeSpec: NodeSpec = { + content: 'task_list_item+', + group: 'block', + parseDOM: [ + { + tag: 'ul.task-list', + getAttrs: (dom) => { + if (dom instanceof HTMLElement) { + return {}; + } + return false; + }, + }, + { + tag: 'ul.contains-task-list', + getAttrs: (dom) => { + if (dom instanceof HTMLElement) { + return {}; + } + return false; + }, + }, + ], + toDOM: () => ['ul', { class: 'task-list' }, 0], +}; + +export const taskListItemNodeSpec: NodeSpec = { + content: 'paragraph', + attrs: { + checked: { default: false }, + }, + parseDOM: [ + { + tag: 'li.task-list-item', + getAttrs: (dom) => { + if (dom instanceof HTMLElement) { + const checkbox = dom.querySelector( + 'input[type="checkbox"]' + ); + return { + checked: checkbox + ? (checkbox as HTMLInputElement).checked + : false, + }; + } + return false; + }, + }, + ], + toDOM: (node) => { + const { checked } = node.attrs; + return [ + 'li', + { class: 'task-list-item' }, + [ + 'input', + { + type: 'checkbox', + checked: checked ? 'checked' : null, + disabled: 'disabled', // Make checkbox non-interactive in HTML output + }, + ], + ['div', { class: 'task-list-item-content' }, 0], + ]; + }, + defining: true, +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss new file mode 100644 index 0000000000..623a6ee120 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss @@ -0,0 +1,49 @@ +/* Task list styles with native checkbox */ +.task-list { + list-style: none; + padding-left: 0; + margin-left: 0; +} + +.task-list-item { + display: flex; + align-items: flex-start; + margin: 0.25rem 0; + position: relative; + list-style: none; + gap: 0.5rem; // Gap between checkbox and content +} + +/* Make sure no bullets appear */ +.task-list-item::before { + display: none !important; +} + +.task-list-item::marker { + display: none; +} + +/* Native checkbox styling */ +.task-list-item input[type='checkbox'] { + flex-shrink: 0; + margin-top: 0.125rem; // Slight offset to align with text baseline + cursor: pointer; +} + +.task-list-item-content { + flex: 1; + min-width: 0; +} + +.task-list-item-content p { + margin: 0; +} + +/* Completed task styling */ +.task-list-item:has(input[type='checkbox']:checked) .task-list-item-content { + opacity: 0.6; + text-decoration: line-through; +} +.task-list-item input[type='checkbox']:checked + .task-list-item-content p { + text-decoration: line-through; +} diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss index 8694219e73..4c6572ade7 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss @@ -9,6 +9,7 @@ @forward '../../markdown/partial-styles/kbd'; @forward '../../markdown/partial-styles/img'; @forward 'plugins/image/view.scss'; +@forward 'plugins/task-list/task-list.scss'; :host(limel-prosemirror-adapter) { display: flex; diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx index fdaa341604..487c085a03 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx @@ -43,6 +43,13 @@ import { createActionBarInteractionPlugin } from './plugins/menu-action-interact import { CustomElementDefinition } from '../../../global/shared-types/custom-element.types'; import { createNodeSpec } from '../utils/plugin-factory'; import { createTriggerPlugin } from './plugins/trigger/factory'; +import { + taskListNodeSpec, + taskListItemNodeSpec, +} from './plugins/task-list/task-list-schema'; +import { createTaskListPlugin } from './plugins/task-list/task-list-plugin'; +import { createTaskListItemNodeView } from './plugins/task-list/task-list-node-view'; +import { createListIndentationPlugin } from './plugins/list-indentation-plugin'; import { TriggerCharacter, ImageInserter, @@ -359,6 +366,9 @@ export class ProsemirrorAdapter { { state: this.createEditorState(initialDoc), dispatchTransaction: this.handleTransaction, + nodeViews: { + task_list_item: createTaskListItemNodeView, + }, } ); @@ -381,6 +391,12 @@ export class ProsemirrorAdapter { } nodes = addListNodes(nodes, 'paragraph block*', 'block'); + // Add task list nodes + nodes = nodes.append({ + task_list: taskListNodeSpec, + task_list_item: taskListItemNodeSpec, + }); + if (this.contentType === 'html') { nodes = nodes.append(getTableNodes()); } @@ -416,6 +432,8 @@ export class ProsemirrorAdapter { return EditorState.create({ doc: initialDoc, plugins: [ + createTaskListPlugin(this.schema), // Move task list plugin FIRST + createListIndentationPlugin(this.schema), // Add list indentation plugin ...exampleSetup({ schema: this.schema, menuBar: false }), keymap(this.menuCommandFactory.buildKeymap()), createTriggerPlugin( diff --git a/src/components/text-editor/text-editor.tsx b/src/components/text-editor/text-editor.tsx index 7cf8deb602..0f2cde8b1b 100644 --- a/src/components/text-editor/text-editor.tsx +++ b/src/components/text-editor/text-editor.tsx @@ -26,6 +26,7 @@ import { EditorUiType } from './types'; * @exampleComponent limel-example-text-editor-with-markdown * @exampleComponent limel-example-text-editor-with-html * @exampleComponent limel-example-text-editor-with-tables + * @exampleComponent limel-example-text-editor-with-task-lists * @exampleComponent limel-example-text-editor-with-inline-images-file-storage * @exampleComponent limel-example-text-editor-with-inline-images-base64 * @exampleComponent limel-example-text-editor-allow-resize diff --git a/src/components/text-editor/utils/markdown-converter.ts b/src/components/text-editor/utils/markdown-converter.ts index f27ddaa06a..ceee9b91a3 100644 --- a/src/components/text-editor/utils/markdown-converter.ts +++ b/src/components/text-editor/utils/markdown-converter.ts @@ -51,6 +51,19 @@ const buildMarkdownSerializer = ( ...defaultMarkdownSerializer.nodes, ...getImageNodeMarkdownSerializer(language), ...customNodes, + // Task list serialization + task_list: (state: MarkdownSerializerState, node: ProseMirrorNode) => { + state.renderList(node, '', () => ''); + }, + task_list_item: ( + state: MarkdownSerializerState, + node: ProseMirrorNode + ) => { + const checked = node.attrs.checked; + const checkbox = checked ? '[x]' : '[ ]'; + state.write(`- ${checkbox} `); + state.renderContent(node); + }, }; const marks = { @@ -78,7 +91,9 @@ export class MarkdownConverter implements ContentTypeConverter { this.customNodes = plugins; } public parseAsHTML = (text: string): Promise => { - return markdownToHTML(text, { whitelist: this.customNodes }); + return markdownToHTML(text, { + whitelist: this.customNodes, + }); }; public serialize = (view: EditorView): string => { diff --git a/src/translations/en.ts b/src/translations/en.ts index 482361b034..f5601e904b 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -39,6 +39,7 @@ export default { 'editor-menu.h3': 'Heading 3', 'editor-menu.bulleted-list': 'Bulleted list', 'editor-menu.numbered-list': 'Numbered list', + 'editor-menu.task-list': 'Task list', 'editor-menu.blockquote': 'Blockquote', 'editor-menu.link': 'Link', 'editor-link-menu.text': 'Text', diff --git a/src/translations/no.ts b/src/translations/no.ts index 41db144b7a..e19fbf5236 100644 --- a/src/translations/no.ts +++ b/src/translations/no.ts @@ -38,6 +38,7 @@ export default { 'editor-menu.h3': 'Overskrifts 3', 'editor-menu.bulleted-list': 'Punktliste', 'editor-menu.numbered-list': 'Nummerert liste', + 'editor-menu.task-list': 'Oppgaveliste', 'editor-menu.blockquote': 'Blokksitat', 'editor-menu.link': 'Legg til lenke', 'editor-link-menu.text': 'Tekst', diff --git a/src/translations/sv.ts b/src/translations/sv.ts index bc3dc15fd8..618b0bd951 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -39,6 +39,7 @@ export default { 'editor-menu.h3': 'Rubrik 3', 'editor-menu.bulleted-list': 'Punktlista', 'editor-menu.numbered-list': 'Numrerad lista', + 'editor-menu.task-list': 'Uppgiftslista', 'editor-menu.blockquote': 'Blockcitat', 'editor-menu.link': 'Lägg till länk', 'editor-link-menu.text': 'Text',