From 94b42b9e35267b9e1f28d4e7abe7064fee318528 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Sun, 7 Sep 2025 23:43:56 +0200 Subject: [PATCH] feat: introduce lists (WIP) --- packages/vuetify/src/labs/VEditor/VEditor.tsx | 17 +++- .../src/labs/VEditor/composables/formatter.ts | 80 ++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/vuetify/src/labs/VEditor/VEditor.tsx b/packages/vuetify/src/labs/VEditor/VEditor.tsx index 820887b0beb..1f12847a106 100644 --- a/packages/vuetify/src/labs/VEditor/VEditor.tsx +++ b/packages/vuetify/src/labs/VEditor/VEditor.tsx @@ -15,7 +15,7 @@ import { VToolbar } from '@/components/VToolbar/VToolbar' // Composables import { useCaret, useElement, useSelection } from './composables' -import { alignmentFormats, FormatCategory, generalFormats, headingFormats, useFormatter } from './composables/formatter' +import { alignmentFormats, FormatCategory, generalFormats, headingFormats, listFormats, useFormatter } from './composables/formatter' import { useFocus } from '@/composables/focus' import { forwardRefs } from '@/composables/forwardRefs' import { useProxiedModel } from '@/composables/proxiedModel' @@ -58,7 +58,9 @@ export const makeVEditorProps = propsFactory({ 'align-left', 'align-right', 'align-justify', - 'highlight', + 'list-unordered', + 'list-ordered', + 'list-tasks', ], }, height: { @@ -121,6 +123,7 @@ export const VEditor = genericComponent()({ const displayedGeneralFormats = computed(() => generalFormats.filter(format => props.formats.includes(format.name))) const displayedHeadingFormats = computed(() => headingFormats.filter(format => props.formats.includes(format.name))) const displayedAlignmentFormats = computed(() => alignmentFormats.filter(format => props.formats.includes(format.name))) + const displayedListFormats = computed(() => listFormats.filter(format => props.formats.includes(format.name))) function onMouseUp () { updateActiveFormats() @@ -249,7 +252,12 @@ export const VEditor = genericComponent()({ const newActiveFormats = new Set() - const allDisplayedFormats = [...displayedGeneralFormats.value, ...displayedHeadingFormats.value, ...displayedAlignmentFormats.value] + const allDisplayedFormats = [ + ...displayedGeneralFormats.value, + ...displayedHeadingFormats.value, + ...displayedAlignmentFormats.value, + ...displayedListFormats.value, + ] allDisplayedFormats.forEach((format: Formatter) => { if (formatter.findElementWithFormat(format)) { @@ -267,6 +275,8 @@ export const VEditor = genericComponent()({ formatter.heading.toggle(format) } else if (format.category === FormatCategory.Alignment) { formatter.alignment.toggle(format) + } else if (format.category === FormatCategory.List) { + formatter.list.toggle(format) } else { formatter.inline.toggle(format) } @@ -423,6 +433,7 @@ export const VEditor = genericComponent()({ {[ displayedHeadingFormats.value, displayedAlignmentFormats.value, + displayedListFormats.value, ] .map(groupFormats => { const activeFormat = groupFormats.find(format => activeFormats.value.has(format.name)) diff --git a/packages/vuetify/src/labs/VEditor/composables/formatter.ts b/packages/vuetify/src/labs/VEditor/composables/formatter.ts index a13e2de6fda..271ce799603 100644 --- a/packages/vuetify/src/labs/VEditor/composables/formatter.ts +++ b/packages/vuetify/src/labs/VEditor/composables/formatter.ts @@ -26,18 +26,26 @@ export type Formats = 'block' | 'align-center' | 'align-left' | 'align-right' | - 'align-justify' + 'align-justify' | + 'list-unordered' | + 'list-ordered' | + 'list-tasks' export enum FormatCategory { Heading = 'heading', Alignment = 'alignment', + List = 'list', } export type Formatter = { name: Formats icon: string category?: FormatCategory - config: { tag?: string, styles?: Record} + config: { + tag?: string + tagCondition?: string + styles?: Record + } } export const blockFormatter: Formatter = { @@ -155,6 +163,27 @@ export const alignmentFormats: Formatter[] = [ }, ] +export const listFormats: Formatter[] = [ + { + name: 'list-unordered', + icon: 'mdi-format-list-bulleted', + category: FormatCategory.List, + config: { tag: 'ul' }, + }, + { + name: 'list-ordered', + icon: 'mdi-format-list-numbered', + category: FormatCategory.List, + config: { tag: 'ol' }, + }, + { + name: 'list-tasks', + icon: 'mdi-format-list-checks', + category: FormatCategory.List, + config: { tag: 'ul', tagCondition: '>li>input[type="checkbox"]' }, + }, +] + export function useFormatter (editorRef: Ref) { const selection = useSelection(editorRef) const caret = useCaret(editorRef) @@ -173,7 +202,7 @@ export function useFormatter (editorRef: Ref) { } function isApplied (format: Formatter, element: Element) { - const { tag, styles } = format.config + const { tag, tagCondition, styles } = format.config const hasSameTag = tag ? element.tagName.toLowerCase() === tag.toLowerCase() : true @@ -305,6 +334,46 @@ export function useFormatter (editorRef: Ref) { } } + function toggleListFormat (format: Formatter) { + const blockElement = editorElement.getCurrentBlock() + + if (!editorRef.value) return + + if (!blockElement) { + caret.save() + formatElementChildren(editorRef.value, format) + caret.restore() + } else { + const closestListParent = blockElement.closest('ul,ol') + const closestListItemParent = blockElement.closest('li') + const isTaskList = !!closestListItemParent?.children[0] && + closestListItemParent.children[0].tagName === 'INPUT' && + closestListItemParent.children[0].getAttribute('type') === 'checkbox' + + const currentListType = isTaskList ? 'tasks' : closestListParent?.tagName.toLowerCase() + const targetListType = format.name.endsWith('-tasks') ? 'tasks' : format.name.endsWith('-ordered') ? 'ol' : 'ul' + + if (currentListType && currentListType === targetListType) { + // TODO: unwrap selected `
  • ` nodes from the list + // TODO: leave unselected `
  • ` nodes within list (split if necessary) + + // experimenting to have anything close... + closestListParent?.replaceWith(blockElement) + } else { + const listTag = targetListType === 'ol' ? 'ol' : 'ul' + const newList = document.createElement(listTag) + // TODO: wrap all lines/paragraphs individually, ignore/drop
    + // TODO: merge with neighbouring lists if possible + + // experimenting to have anything close... + const newListItem = document.createElement('li') + blockElement.prepend(newList) + newList.appendChild(newListItem) + newList.appendChild(blockElement) + } + } + } + const inline = { toggle: toggleInlineFormat, add: addInlineFormat, @@ -319,6 +388,10 @@ export function useFormatter (editorRef: Ref) { toggle: toggleAlignmentFormat, } + const list = { + toggle: toggleListFormat, + } + return { isApplied, findElementWithFormat, @@ -326,5 +399,6 @@ export function useFormatter (editorRef: Ref) { inline, heading, alignment, + list, } }