Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fileverse-dev/fortune-core",
"version": "1.3.11",
"version": "1.3.12",
"main": "lib/index.js",
"module": "es/index.js",
"typings": "lib/index.d.ts",
Expand Down
147 changes: 105 additions & 42 deletions packages/core/src/modules/inline-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,15 @@ function extendCssText(origin: string, cover: string, isLimit = true) {
return newCss;
}

function escapeHtmlAttr(s: string): string {
return s
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

export function updateInlineStringFormat(
ctx: Context,
attr: keyof Cell,
Expand All @@ -381,6 +390,46 @@ export function updateInlineStringFormat(

const $textEditor = cellInput;

// Firefox (and sometimes other browsers) can produce element-based ranges
// (e.g. Ctrl+A selects node contents: startContainer/endContainer are the editor DIV
// and offsets are child-node indexes, not character indexes). The legacy string-slicing
// logic below treats offsets as character positions and can accidentally inject the
// editor's own outerHTML into itself. Handle element-based ranges safely first.
if (
range.startContainer === $textEditor &&
range.endContainer === $textEditor &&
range.collapsed === false
) {
const start = range.startOffset;
const end = range.endOffset;

const children = Array.from($textEditor.childNodes).slice(start, end);
children.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === "SPAN") {
const cssText = getCssText(el.style.cssText, attr, value);
el.setAttribute("style", cssText);
}
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? "";
if (text.length === 0) return;
const wrapper = document.createElement("span");
const cssText = getCssText("", attr, value);
wrapper.setAttribute("style", cssText);
wrapper.textContent = text;
node.parentNode?.replaceChild(wrapper, node);
}
});

// Restore selection across the whole editor contents.
const newRange = document.createRange();
newRange.selectNodeContents($textEditor);
w.removeAllRanges();
w.addRange(newRange);
return;
}

if (range.collapsed === true) {
return;
}
Expand Down Expand Up @@ -426,7 +475,7 @@ export function updateInlineStringFormat(
cssText = extendCssText(box.style.cssText, cssText);
}
}
cont += `<span style='${cssText}'>${left}</span>`;
cont += `<span style="${escapeHtmlAttr(cssText)}">${left}</span>`;
}

if (mid !== "") {
Expand All @@ -441,7 +490,7 @@ export function updateInlineStringFormat(
}
}

cont += `<span style='${cssText}'>${mid}</span>`;
cont += `<span style="${escapeHtmlAttr(cssText)}">${mid}</span>`;
}

if (right !== "") {
Expand All @@ -454,7 +503,7 @@ export function updateInlineStringFormat(
cssText = extendCssText(box.style.cssText, cssText);
}
}
cont += `<span style='${cssText}'>${right}</span>`;
cont += `<span style="${escapeHtmlAttr(cssText)}">${right}</span>`;
}

if (startContainer.parentElement?.tagName === "SPAN") {
Expand Down Expand Up @@ -510,38 +559,48 @@ export function updateInlineStringFormat(
for (let i = 0; i < startSpanIndex; i += 1) {
const span = spans[i];
const content = span.innerHTML;
cont += `<span style='${span.style.cssText}'>${content}</span>`;
cont += `<span style="${escapeHtmlAttr(
span.style.cssText
)}">${content}</span>`;
}
if (sleft !== "") {
cont += `<span style='${startSpan!.style.cssText}'>${sleft}</span>`;
cont += `<span style="${escapeHtmlAttr(
startSpan!.style.cssText
)}">${sleft}</span>`;
}

if (sright !== "") {
const cssText = getCssText(startSpan!.style.cssText, attr, value);
cont += `<span style='${cssText}'>${sright}</span>`;
cont += `<span style="${escapeHtmlAttr(cssText)}">${sright}</span>`;
}

if (startSpanIndex < endSpanIndex) {
for (let i = startSpanIndex + 1; i < endSpanIndex; i += 1) {
const span = spans[i];
const content = span.innerHTML;
cont += `<span style='${span.style.cssText}'>${content}</span>`;
cont += `<span style="${escapeHtmlAttr(
span.style.cssText
)}">${content}</span>`;
}
}

if (eleft !== "") {
const cssText = getCssText(endSpan!.style.cssText, attr, value);
cont += `<span style='${cssText}'>${eleft}</span>`;
cont += `<span style="${escapeHtmlAttr(cssText)}">${eleft}</span>`;
}

if (eright !== "") {
cont += `<span style='${endSpan!.style.cssText}'>${eright}</span>`;
cont += `<span style="${escapeHtmlAttr(
endSpan!.style.cssText
)}">${eright}</span>`;
}

for (let i = endSpanIndex + 1; i < spans.length; i += 1) {
const span = spans[i];
const content = span.innerHTML;
cont += `<span style='${span.style.cssText}'>${content}</span>`;
cont += `<span style="${escapeHtmlAttr(
span.style.cssText
)}">${content}</span>`;
}

$textEditor.innerHTML = cont;
Expand Down Expand Up @@ -570,19 +629,11 @@ export function updateInlineStringFormat(
}
}

function escapeHtmlAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

function getLinkDataAttrs(span: HTMLElement): string {
if (span.dataset?.linkType && span.dataset?.linkAddress) {
return ` data-link-type='${escapeHtmlAttr(
return ` data-link-type="${escapeHtmlAttr(
span.dataset.linkType
)}' data-link-address='${escapeHtmlAttr(span.dataset.linkAddress)}'`;
)}" data-link-address="${escapeHtmlAttr(span.dataset.linkAddress)}"`;
}
return "";
}
Expand Down Expand Up @@ -628,7 +679,9 @@ export function applyLinkToSelection(
) as HTMLElement | null;
if (box != null) cssText = extendCssText(box.style.cssText, cssText);
}
cont += `<span style='${cssText}'${dataAttrs}>${left}</span>`;
cont += `<span style="${escapeHtmlAttr(
cssText
)}"${dataAttrs}>${left}</span>`;
}
if (mid !== "") {
let cssText = getLinkStyleCssText(span!.style.cssText);
Expand All @@ -638,9 +691,11 @@ export function applyLinkToSelection(
) as HTMLElement | null;
if (box != null) cssText = extendCssText(box.style.cssText, cssText);
}
cont += `<span style='${cssText}' data-link-type='${escapeHtmlAttr(
cont += `<span style="${escapeHtmlAttr(
cssText
)}" data-link-type="${escapeHtmlAttr(
linkType
)}' data-link-address='${escapeHtmlAttr(linkAddress)}'>${mid}</span>`;
)}" data-link-address="${escapeHtmlAttr(linkAddress)}">${mid}</span>`;
}
if (right !== "") {
let { cssText } = span!.style;
Expand All @@ -650,7 +705,9 @@ export function applyLinkToSelection(
) as HTMLElement | null;
if (box != null) cssText = extendCssText(box.style.cssText, cssText);
}
cont += `<span style='${cssText}'${dataAttrs}>${right}</span>`;
cont += `<span style="${escapeHtmlAttr(
cssText
)}"${dataAttrs}>${right}</span>`;
}
if (startContainer.parentElement?.tagName === "SPAN") {
(span as HTMLElement).outerHTML = cont;
Expand Down Expand Up @@ -683,48 +740,54 @@ export function applyLinkToSelection(
let cont = "";
for (let i = 0; i < startSpanIndex; i += 1) {
const sp = spans[i] as HTMLElement;
cont += `<span style='${sp.style.cssText}'${getLinkDataAttrs(sp)}>${
sp.innerHTML
}</span>`;
cont += `<span style="${escapeHtmlAttr(
sp.style.cssText
)}"${getLinkDataAttrs(sp)}>${sp.innerHTML}</span>`;
}
if (sleft !== "") {
cont += `<span style='${startSpan!.style.cssText}'${getLinkDataAttrs(
startSpan!
)}>${sleft}</span>`;
cont += `<span style="${escapeHtmlAttr(
startSpan!.style.cssText
)}"${getLinkDataAttrs(startSpan!)}>${sleft}</span>`;
}
if (sright !== "") {
const cssText = getLinkStyleCssText(startSpan!.style.cssText);
cont += `<span style='${cssText}' data-link-type='${escapeHtmlAttr(
cont += `<span style="${escapeHtmlAttr(
cssText
)}" data-link-type="${escapeHtmlAttr(
linkType
)}' data-link-address='${escapeHtmlAttr(linkAddress)}'>${sright}</span>`;
)}" data-link-address="${escapeHtmlAttr(linkAddress)}">${sright}</span>`;
}
if (startSpanIndex < endSpanIndex) {
for (let i = startSpanIndex + 1; i < endSpanIndex; i += 1) {
const sp = spans[i];
const cssText = getLinkStyleCssText(sp.style.cssText);
cont += `<span style='${cssText}' data-link-type='${escapeHtmlAttr(
cont += `<span style="${escapeHtmlAttr(
cssText
)}" data-link-type="${escapeHtmlAttr(
linkType
)}' data-link-address='${escapeHtmlAttr(linkAddress)}'>${
)}" data-link-address="${escapeHtmlAttr(linkAddress)}">${
sp.innerHTML
}</span>`;
}
}
if (eleft !== "") {
const cssText = getLinkStyleCssText(endSpan!.style.cssText);
cont += `<span style='${cssText}' data-link-type='${escapeHtmlAttr(
cont += `<span style="${escapeHtmlAttr(
cssText
)}" data-link-type="${escapeHtmlAttr(
linkType
)}' data-link-address='${escapeHtmlAttr(linkAddress)}'>${eleft}</span>`;
)}" data-link-address="${escapeHtmlAttr(linkAddress)}">${eleft}</span>`;
}
if (eright !== "") {
cont += `<span style='${endSpan!.style.cssText}'${getLinkDataAttrs(
endSpan!
)}>${eright}</span>`;
cont += `<span style="${escapeHtmlAttr(
endSpan!.style.cssText
)}"${getLinkDataAttrs(endSpan!)}>${eright}</span>`;
}
for (let i = endSpanIndex + 1; i < spans.length; i += 1) {
const sp = spans[i] as HTMLElement;
cont += `<span style='${sp.style.cssText}'${getLinkDataAttrs(sp)}>${
sp.innerHTML
}</span>`;
cont += `<span style="${escapeHtmlAttr(
sp.style.cssText
)}"${getLinkDataAttrs(sp)}>${sp.innerHTML}</span>`;
}
$textEditor.innerHTML = cont;
spans = $textEditor.querySelectorAll("span");
Expand Down
4 changes: 2 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fileverse-dev/fortune-react",
"version": "1.3.11",
"version": "1.3.12",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"module": "es/index.js",
Expand All @@ -16,7 +16,7 @@
"tsc": "tsc"
},
"dependencies": {
"@fileverse-dev/fortune-core": "1.3.11",
"@fileverse-dev/fortune-core": "1.3.12",
"@fileverse/ui": "5.0.0",
"@tippyjs/react": "^4.2.6",
"@types/regenerator-runtime": "^0.13.6",
Expand Down
Loading