diff --git a/packages/super-editor/src/editors/v1/components/toolbar/BulletStyleButtons.vue b/packages/super-editor/src/editors/v1/components/toolbar/BulletStyleButtons.vue new file mode 100644 index 0000000000..e9dc1e5e9b --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/BulletStyleButtons.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index a16e4b00dc..f9c6206b8a 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -4,6 +4,7 @@ import { sanitizeNumber } from './helpers'; import { useToolbarItem } from './use-toolbar-item'; import AIWriter from './AIWriter.vue'; import AlignmentButtons from './AlignmentButtons.vue'; +import BulletStyleButtons from './BulletStyleButtons.vue'; import DocumentMode from './DocumentMode.vue'; import LinkedStyle from './LinkedStyle.vue'; import LinkInput from './LinkInput.vue'; @@ -630,16 +631,34 @@ export const makeDefaultItems = ({ // bullet list const bulletedList = useToolbarItem({ - type: 'button', + type: 'dropdown', name: 'list', - command: 'toggleBulletList', + command: 'toggleBulletListStyle', icon: toolbarIcons.bulletList, - active: false, + hasCaret: true, tooltip: toolbarTexts.bulletList, restoreEditorFocus: true, + suppressActiveHighlight: true, attributes: { ariaLabel: 'Bullet list', }, + options: [ + { + type: 'render', + key: 'bullet-style-buttons', + render: () => { + const handleSelect = (style) => { + closeDropdown(bulletedList); + const item = { ...bulletedList, command: 'toggleBulletListStyle' }; + superToolbar.emitCommand({ item, argument: style }); + }; + return h(BulletStyleButtons, { + selectedStyle: bulletedList.selectedValue.value, + onSelect: handleSelect, + }); + }, + }, + ], }); // number list diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index c290c31ad7..28db7b3885 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -20,6 +20,7 @@ import { useToolbarItem } from '@components/toolbar/use-toolbar-item'; import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; import { parseSizeUnit } from '@core/utilities'; import { findElementBySelector, getParagraphFontFamilyFromProperties } from './helpers/general.js'; +import { markerTextToBulletStyle } from '@helpers/list-numbering-helpers.js'; /** * @typedef {function(CommandItem): void} CommandCallback @@ -622,6 +623,15 @@ export class SuperToolbar extends EventEmitter { if (commandState?.value != null) item.activate({ styleId: commandState.value }); else item.label.value = this.config.texts?.formatText || 'Format text'; }, + list: () => { + if (commandState?.active) { + item.activate(); + item.selectedValue.value = markerTextToBulletStyle(commandState.value); + } else { + item.deactivate(); + item.selectedValue.value = null; + } + }, default: () => { if (commandState?.active) item.activate(); else item.deactivate(); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js index 70f62342f4..8b8caf0f08 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js @@ -2,6 +2,8 @@ import boldIconSvg from '@superdoc/common/icons/bold-solid.svg?raw'; import italicIconSvg from '@superdoc/common/icons/italic-solid.svg?raw'; import underlineIconSvg from '@superdoc/common/icons/underline-solid.svg?raw'; import listIconSvg from '@superdoc/common/icons/list-solid.svg?raw'; +import listCircleIconSvg from '@superdoc/common/icons/list-circle-solid.svg?raw'; +import listSquareIconSvg from '@superdoc/common/icons/list-square-solid.svg?raw'; import listOlIconSvg from '@superdoc/common/icons/list-ol-solid.svg?raw'; import imageIconSvg from '@superdoc/common/icons/image-solid.svg?raw'; import linkIconSvg from '@superdoc/common/icons/link-solid.svg?raw'; @@ -64,6 +66,9 @@ export const toolbarIcons = { alignCenter: alignCenterIconSvg, alignJustify: alignJustifyIconSvg, bulletList: listIconSvg, + bulletListDisc: listIconSvg, + bulletListCircle: listCircleIconSvg, + bulletListSquare: listSquareIconSvg, numberedList: listOlIconSvg, indentLeft: outdentIconSvg, indentRight: indentIconSvg, diff --git a/packages/super-editor/src/editors/v1/core/commands/toggleList.js b/packages/super-editor/src/editors/v1/core/commands/toggleList.js index 048bd03ec0..7aebd43a76 100644 --- a/packages/super-editor/src/editors/v1/core/commands/toggleList.js +++ b/packages/super-editor/src/editors/v1/core/commands/toggleList.js @@ -1,6 +1,6 @@ // @ts-check import { updateNumberingProperties } from './changeListLevel.js'; -import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { ListHelpers, markerTextToBulletStyle } from '@helpers/list-numbering-helpers.js'; import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; import { isVisuallyEmptyParagraph } from './removeNumberingProperties.js'; import { Selection, TextSelection } from 'prosemirror-state'; @@ -26,10 +26,15 @@ function getParagraphListKind(node, editor) { return numFmtIsBullet(fmt) ? 'bullet' : 'ordered'; } -function paragraphMatchesToggleListType(node, editor, listType) { +function paragraphMatchesToggleListType(node, editor, listType, bulletStyle) { const kind = getParagraphListKind(node, editor); if (!kind) return false; - if (listType === 'bulletList') return kind === 'bullet'; + if (listType === 'bulletList') { + if (kind !== 'bullet') return false; + if (!bulletStyle) return true; + const markerText = node.attrs.listRendering?.markerText; + return markerTextToBulletStyle(markerText) === bulletStyle; + } if (listType === 'orderedList') return kind === 'ordered'; return false; } @@ -60,13 +65,13 @@ function getPrecedingParagraphForListReuse(doc, from, paragraphsInSelection) { } export const toggleList = - (listType) => + (listType, bulletStyle) => ({ editor, state, tr, dispatch }) => { if (listType !== 'orderedList' && listType !== 'bulletList') { return false; } - const predicate = (n) => paragraphMatchesToggleListType(n, editor, listType); + const predicate = (n) => paragraphMatchesToggleListType(n, editor, listType, bulletStyle); const { selection } = state; const { from, to } = selection; let firstListNode = null; @@ -127,7 +132,7 @@ export const toggleList = if (mode === 'create') { const numId = ListHelpers.getNewListId(editor); - ListHelpers.generateNewListDefinition({ numId: Number(numId), listType, editor }); + ListHelpers.generateNewListDefinition({ numId: Number(numId), listType, editor, bulletStyle }); sharedNumberingProperties = { numId: Number(numId), ilvl: 0, diff --git a/packages/super-editor/src/editors/v1/core/helpers/list-numbering-helpers.js b/packages/super-editor/src/editors/v1/core/helpers/list-numbering-helpers.js index 016e536f55..3c584f2b70 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/list-numbering-helpers.js +++ b/packages/super-editor/src/editors/v1/core/helpers/list-numbering-helpers.js @@ -29,6 +29,17 @@ import { mutateNumbering } from '@core/parts/adapters/numbering-mutation'; // Shims will be removed as callers migrate in Phases 1b–1d. // --------------------------------------------------------------------------- +/** + * Maps a bullet marker character (from `listRendering.markerText`) to its named bullet style. + * Returns null for unrecognized markers. + * @param {string|null|undefined} markerText + * @returns {'disc'|'circle'|'square'|null} + */ +export function markerTextToBulletStyle(markerText) { + const map = { '•': 'disc', '◦': 'circle', '▪': 'square' }; + return map[markerText] ?? null; +} + /** * Generate a new list definition for the given list type. * @param {Object} param0 @@ -39,10 +50,21 @@ import { mutateNumbering } from '@core/parts/adapters/numbering-mutation'; * @param {string} [param0.text] * @param {string} [param0.fmt] * @param {string} [param0.markerFontFamily] + * @param {'disc'|'circle'|'square'} [param0.bulletStyle] * @param {import('../Editor').Editor} param0.editor * @returns {Object} The new abstract and num definitions. */ -export const generateNewListDefinition = ({ numId, listType, level, start, text, fmt, editor, markerFontFamily }) => { +export const generateNewListDefinition = ({ + numId, + listType, + level, + start, + text, + fmt, + editor, + markerFontFamily, + bulletStyle, +}) => { /** @type {{ abstractDef: any, numDef: any }} */ let resultDefs; @@ -55,6 +77,7 @@ export const generateNewListDefinition = ({ numId, listType, level, start, text, text, fmt, markerFontFamily, + bulletStyle, }); resultDefs = { abstractDef: result.abstractDef, numDef: result.numDef }; }); diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts index 84d16a318f..487a80a99e 100644 --- a/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts +++ b/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts @@ -30,8 +30,15 @@ interface GenerateOptions { text?: string | null; fmt?: string | null; markerFontFamily?: string | null; + bulletStyle?: 'disc' | 'circle' | 'square' | null; } +const BULLET_STYLE_CHARS: Record = { + disc: '•', + circle: '◦', + square: '▪', +}; + interface GenerateResult { numId: number; abstractId: number; @@ -72,7 +79,7 @@ function buildNumDef(numId: number, abstractId: number): any { */ export function generateNewListDefinition(numbering: NumberingModel, options: GenerateOptions): GenerateResult { let { listType } = options; - const { numId, level, start, text, fmt, markerFontFamily } = options; + const { numId, level, start, text, fmt, markerFontFamily, bulletStyle } = options; if (typeof listType !== 'string') listType = (listType as any).name; const definition = listType === 'orderedList' ? baseOrderedListDef : baseBulletList; @@ -86,6 +93,25 @@ export function generateNewListDefinition(numbering: NumberingModel, options: Ge }), ); + // Override the bullet style for the new list if a bullet style is provided + const shouldOverrideBulletStyle = bulletStyle && listType !== 'orderedList'; + if (shouldOverrideBulletStyle) { + const char = BULLET_STYLE_CHARS[bulletStyle]; + + if (char) { + const lvl0 = newAbstractDef.elements.find((el: any) => el.name === 'w:lvl' && el.attributes['w:ilvl'] === '0'); + + if (lvl0) { + const lvlText = lvl0.elements.find((el: any) => el.name === 'w:lvlText'); + if (lvlText) lvlText.attributes['w:val'] = char; + + // Remove any inherited font so the Unicode char renders in the document's default font + const rPr = lvl0.elements.find((el: any) => el.name === 'w:rPr'); + if (rPr) rPr.elements = rPr.elements.filter((el: any) => el.name !== 'w:rFonts'); + } + } + } + if (level != null && start != null && text != null && fmt != null) { if (numbering.definitions[numId]) { const abstractId = numbering.definitions[numId]?.elements[0]?.attributes['w:val']; diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 62525501c8..0d51b7e617 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -173,6 +173,7 @@ export const Paragraph = OxmlNode.create({ listRendering: { keepOnSplit: false, renderDOM: ({ listRendering }) => { + console.log('listRendering', listRendering); return { 'data-marker-type': listRendering?.markerText, 'data-list-level': listRendering?.path ? JSON.stringify(listRendering.path) : null, @@ -323,6 +324,17 @@ export const Paragraph = OxmlNode.create({ return toggleList('bulletList')(params); }, + /** + * Toggle a bullet list with a specific bullet style at the current selection + * @category Command + * @example + * editor.commands.toggleBulletListStyle('disc') + * @note Style can be 'disc' (•), 'circle' (◦), or 'square' (▪) + */ + toggleBulletListStyle: (style) => (params) => { + return toggleList('bulletList', style)(params); + }, + /** * Restart numbering for the current list * @category Command diff --git a/packages/super-editor/src/editors/v1/extensions/types/paragraph-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/paragraph-commands.ts index a8f68ec827..06681a3037 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/paragraph-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/paragraph-commands.ts @@ -15,6 +15,9 @@ export interface ParagraphCommands { /** Toggle bullet list formatting on the current selection */ toggleBulletList: () => boolean; + /** Toggle a bullet list with a specific style ('disc' | 'circle' | 'square') */ + toggleBulletListStyle: (style: 'disc' | 'circle' | 'square') => boolean; + /** Restart numbering for the current list item */ restartNumbering: () => boolean; diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index 53019dbe9d..41a057582c 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -473,10 +473,10 @@ describe('createHeadlessToolbar', () => { }); it('executes bullet-list through the registry direct command path', () => { - const toggleBulletList = vi.fn(() => true); + const toggleBulletListStyle = vi.fn(() => true); const superdoc = createActiveEditorHost({ commands: { - toggleBulletList, + toggleBulletListStyle, }, state: createSelectionState({ empty: true, @@ -495,7 +495,7 @@ describe('createHeadlessToolbar', () => { }); expect(controller.execute?.('bullet-list')).toBe(true); - expect(toggleBulletList).toHaveBeenCalledTimes(1); + expect(toggleBulletListStyle).toHaveBeenCalledTimes(1); controller.destroy(); }); diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 9327cb71fd..8ff4b0598c 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -117,6 +117,11 @@ export const createListStateDeriver = ? activeNumberingType === 'bullet' : activeNumberingType != null && activeNumberingType !== 'bullet'; + if (numberingType === 'bullet') { + const markerText = isActive ? (paragraphNode?.attrs?.listRendering?.markerText ?? null) : null; + return { active: isActive, disabled: false, value: markerText }; + } + return { active: isActive, disabled: false, diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index a0a75f3510..9d705a752f 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -542,6 +542,7 @@ describe('createToolbarRegistry', () => { expect(state).toEqual({ active: true, disabled: false, + value: null, }); }); diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts index a06a49ef71..a4026e9422 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts @@ -119,7 +119,7 @@ export const createToolbarRegistry = (): Partial diff --git a/shared/common/icons/list-square-solid.svg b/shared/common/icons/list-square-solid.svg new file mode 100644 index 0000000000..dd35473232 --- /dev/null +++ b/shared/common/icons/list-square-solid.svg @@ -0,0 +1 @@ +