feat: Ordered alphabetical lists (#10079)

* feat: letter-list

* simplify list toggle

* use more common shortcuts for list toggle

* fix toggle list conflict

* wrap letter index to avoid overflow

* ensure the markdown letter representation matches the css representation on overflow

* improve list style validation

* fix list indexing

* fix: Toggling ordered lists from formatting menu

* fix: Ordered list in block menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Salihu
2025-09-13 14:03:19 +01:00
committed by GitHub
parent fc469ef9c2
commit f57a189077
2 changed files with 133 additions and 26 deletions

View File

@@ -8,7 +8,8 @@ import clearNodes from "./clearNodes";
export default function toggleList(
listType: NodeType,
itemType: NodeType
itemType: NodeType,
listStyle?: string
): Command {
return (state, dispatch) => {
const { schema, selection } = state;
@@ -25,36 +26,49 @@ export default function toggleList(
);
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
const currentStyle = parentList.node.attrs.listStyle;
const differentListStyle = currentStyle && currentStyle !== listStyle;
if (
parentList.node.type === listType &&
(!differentListStyle || !listStyle)
) {
return liftListItem(itemType)(state, dispatch);
}
const currentItemType = parentList.node.content.firstChild?.type;
if (currentItemType && currentItemType !== itemType) {
return chainTransactions(clearNodes(), wrapInList(listType))(
state,
dispatch
);
const differentType = currentItemType && currentItemType !== itemType;
if (differentType || differentListStyle) {
return chainTransactions(
clearNodes(),
wrapInList(listType, { listStyle })
)(state, dispatch);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
) {
tr.setNodeMarkup(parentList.pos, listType);
tr.setNodeMarkup(
parentList.pos,
listType,
listStyle ? { listStyle } : {}
);
dispatch?.(tr);
return false;
}
}
const canWrapInList = wrapInList(listType)(state);
const attrs = listStyle ? { listStyle } : undefined;
const canWrapInList = wrapInList(listType, attrs)(state);
if (canWrapInList) {
return wrapInList(listType)(state, dispatch);
return wrapInList(listType, attrs)(state, dispatch);
}
return chainTransactions(clearNodes(), wrapInList(listType))(
return chainTransactions(clearNodes(), wrapInList(listType, attrs))(
state,
dispatch
);

View File

@@ -22,6 +22,11 @@ export default class OrderedList extends Node {
default: 1,
validate: "number",
},
listStyle: {
default: "number",
validate: (style: string) =>
["number", "lower-alpha", "upper-alpha"].includes(style),
},
},
content: "list_item+",
group: "block list",
@@ -32,23 +37,43 @@ export default class OrderedList extends Node {
order: dom.hasAttribute("start")
? parseInt(dom.getAttribute("start") || "1", 10)
: 1,
listStyle: dom.style.listStyleType,
}),
},
],
toDOM: (node) =>
node.attrs.order === 1
? ["ol", 0]
: ["ol", { start: node.attrs.order }, 0],
toDOM: (node) => {
const attrs: { start?: number; style?: string } = {};
if (node.attrs.order !== 1) {
attrs.start = node.attrs.order;
}
if (node.attrs.listStyle !== "number") {
attrs.style = `list-style-type: ${node.attrs.listStyle}`;
}
return ["ol", attrs, 0];
},
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return () => toggleList(type, schema.nodes.list_item);
return {
ordered_list: () => toggleList(type, schema.nodes.list_item),
toggleLowerLetterList: () =>
toggleList(type, schema.nodes.list_item, "lower-alpha"),
toggleUpperLetterList: () =>
toggleList(type, schema.nodes.list_item, "upper-alpha"),
};
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
return {
"Shift-Ctrl-9": toggleList(type, schema.nodes.list_item),
"Shift-Ctrl-5": this.commands({ type, schema }).toggleUpperLetterList(),
"Shift-Ctrl-6": this.commands({ type, schema }).toggleLowerLetterList(),
};
}
@@ -57,9 +82,39 @@ export default class OrderedList extends Node {
wrappingInputRule(
/^(\d+)\.\s$/,
type,
(match) => ({ order: +match[1] }),
(match) => ({ order: +match[1], listStyle: "number" }),
(match, node) => node.childCount + node.attrs.order === +match[1]
),
wrappingInputRule(
/^([a-z])\.\s$/,
type,
(match) => ({
order: match[1].charCodeAt(0) - 96,
listStyle: "lower-alpha",
}),
(match, node) => {
const expectedChar = String.fromCharCode(
96 + node.childCount + node.attrs.order
);
return expectedChar === match[1];
}
),
wrappingInputRule(
/^([A-Z])\.\s$/,
type,
(match) => ({
order: match[1].charCodeAt(0) - 64,
listStyle: "upper-alpha",
}),
(match, node) => {
const expectedChar = String.fromCharCode(
64 + node.childCount + node.attrs.order
);
return expectedChar === match[1];
}
),
];
}
@@ -67,21 +122,59 @@ export default class OrderedList extends Node {
state.write("\n");
const start = node.attrs.order !== undefined ? node.attrs.order : 1;
const maxW = `${start + node.childCount - 1}`.length;
const space = state.repeat(" ", maxW + 2);
const upperOrLowerAlpha =
node.attrs.listStyle === "lower-alpha" ||
node.attrs.listStyle === "upper-alpha";
state.renderList(node, space, (index: number) => {
const nStr = `${start + index}`;
return state.repeat(" ", maxW - nStr.length) + nStr + ". ";
});
if (upperOrLowerAlpha) {
const space = state.repeat(" ", 4);
state.renderList(node, space, (index: number) => {
const alphabetSize = 26;
const position = start + index;
const asciiStart = node.attrs.listStyle === "lower-alpha" ? 97 : 65;
let n = position - 1;
let result = "";
do {
const charCode = asciiStart + (n % alphabetSize);
result = String.fromCharCode(charCode) + result;
n = Math.floor(n / alphabetSize) - 1;
} while (n >= 0);
return result + ". ";
});
} else {
const maxW = `${start + node.childCount - 1}`.length;
const space = state.repeat(" ", maxW + 2);
state.renderList(node, space, (index: number) => {
const nStr = `${start + index}`;
return state.repeat(" ", maxW - nStr.length) + nStr + ". ";
});
}
}
parseMarkdown() {
return {
block: "ordered_list",
getAttrs: (tok: Token) => ({
order: parseInt(tok.attrGet("start") || "1", 10),
}),
getAttrs: (tok: Token) => {
const start = tok.attrGet("start") || "1";
let listStyle = "number";
if (tok.markup && /^[a-z]/.test(tok.markup)) {
listStyle = "lower-alpha";
} else if (tok.markup && /^[A-Z]/.test(tok.markup)) {
listStyle = "upper-alpha";
}
return {
order: parseInt(start, 10),
listStyle,
};
},
};
}
}