mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
* 🐛 Fix nil getData crash dropping ZIP without manifest.json Add nil-guard in read-as-text to raise typed :invalid-entry error instead of calling (.getData nil writer) which produced a raw TypeError. Made read-zip-manifest public (was defn-) with explicit detection of missing manifest.json, raising typed :invalid-penpot-file validation error. The existing catch path surfaces this hint as a friendly user error instead of the raw TypeError text. Add regression tests for both paths. 374 users were affected, 704 occurrences across 2.17.0-RC2/RC3/RC4. Fixes #10709. AI-assisted-by: minimax-m3 * 🐛 Harden dnd/get-data against missing dataTransfer When the sortable hook or any caller passes a synthetic event without a dataTransfer property (e.g. a dragend fired after a drop that has already cleared the transfer), the previous implementation called .getData directly on the nil/undefined result and threw "Cannot read properties of undefined (reading 'getData')". Wrap the body in when-let so get-data returns nil cleanly when dataTransfer is missing. All three current callers (hooks.cljs:164, viewport/actions.cljs:531 and :562) already treat the return value as optional via when-let / when, so no caller breaks. Add a regression test covering both the missing-dataTransfer case and a real dataTransfer roundtrip. AI-assisted-by: minimax-m3 * 🐛 Harden paste handler against missing clipboardData in forms When a paste event arrives without a clipboardData property (e.g. a programmatically dispatched ClipboardEvent in some browsers, or edge cases like dragging a file with no text content), the previous implementation called .getData directly on the nil/undefined clipboardData and threw "Cannot read properties of undefined (reading 'getData')". Wrap the body in when-let so the paste logic is skipped entirely when clipboardData is missing. The existing (string? paste-data) guard in the inner when already tolerates nil; no other caller behavior changes. AI-assisted-by: minimax-m3 * 🐛 Harden paste handler against missing clipboardData in components/forms Same defensive pattern as the main/ui/forms.cljs paste handler: wrap the body in when-let so the .getData call is skipped when the clipboardData property is missing on the paste event. Prevents the raw "Cannot read properties of undefined (reading 'getData')" TypeError for programmatic / edge-case paste events. AI-assisted-by: minimax-m3 * 🐛 Harden v3 text editor paste and styles-fn against undefined receivers Two related fixes for the "Cannot read properties of undefined (reading 'getData')" family of bugs in the workspace text editor: - v3_editor.cljs: wrap the paste body in when-let on clipboardData so .getData("text/plain") is never called on a nil receiver. The existing (when (and text (seq text))) guard already tolerates nil text; only the outer .getData call was unprotected. - editor.cljs: add (and content ...) to the if branch in styles-fn so .getText and .getData are never called on a nil content. The else branch (legacy.txt/styles-to-attrs) is already the correct fallback for missing content. Add a regression test that mirrors the fixed patterns and verifies they no longer throw on synthetic events with no clipboardData or nil content. AI-assisted-by: minimax-m3 * 🐛 Harden get-editor-block-data and get-editor-block-type against nil block getCurrentBlock from Draft.js can return undefined for an empty selection (e.g. before any block is created). The previous implementations called .getData / .getType directly on the result and threw "Cannot read properties of undefined (reading 'getData')" / "...reading 'getType')". Wrap both functions in (when (some? block) ...) so they return nil cleanly. Callers in editor.cljs and text_editor.cljs already handle nil results (render-block short-circuits via the case on type; the text-data caller in text_editor.cljs lets nil flow up), so no upstream change is required. Add a regression test covering both functions with nil and js/undefined input. AI-assisted-by: minimax-m3 * 🐛 Harden draft-js block-data helpers against nil block Three related fixes in the vendored draft-js package: - mergeBlockData: early-return undefined when block is falsy. Without this, the first line (block.getData()) throws for callers that pass a nil block. - splitBlockPreservingData: guard the blockMap.get(...) lookup. If the start key is stale (e.g. after a Modifier.splitBlock that doesn't actually produce the expected key), .get() returns undefined and the subsequent .getData() throws. Fall back to an empty Immutable Map for the block data. - updateBlockData: short-circuit (return state unchanged) when mergeBlockData returns undefined. Without this, the chain newBlock.getData() would throw on the same nil-block case that mergeBlockData now guards. These match the defensive nil-handling pattern used elsewhere in the frontend (.getData callers) and protect against stale selection keys in the Draft.js content state. AI-assisted-by: minimax-m3
423 lines
12 KiB
JavaScript
423 lines
12 KiB
JavaScript
/**
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*
|
|
* Copyright (c) KALEIDOS INC Sucursal en España SL
|
|
*/
|
|
import pkg from "draft-js";
|
|
|
|
export const {
|
|
BlockMapBuilder,
|
|
CharacterMetadata,
|
|
CompositeDecorator,
|
|
EditorState,
|
|
Modifier,
|
|
RichTextEditorUtil,
|
|
SelectionState,
|
|
convertFromRaw,
|
|
convertToRaw,
|
|
EditorBlock,
|
|
Editor
|
|
} = pkg;
|
|
|
|
import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js';
|
|
import {Map, OrderedSet} from "immutable";
|
|
|
|
function isDefined(v) {
|
|
return v !== undefined && v !== null;
|
|
}
|
|
|
|
function mergeBlockData(block, newData) {
|
|
if (!block) return undefined;
|
|
let data = block.getData();
|
|
|
|
for (let key of Object.keys(newData)) {
|
|
const oldVal = data.get(key);
|
|
if (oldVal === newData[key]) {
|
|
data = data.delete(key);
|
|
} else {
|
|
data = data.set(key, newData[key]);
|
|
}
|
|
}
|
|
|
|
return block.mergeDeep({
|
|
data: data
|
|
});
|
|
}
|
|
|
|
export function createEditorState(content, decorator) {
|
|
if (content === null) {
|
|
return EditorState.createEmpty(decorator);
|
|
} else {
|
|
return EditorState.createWithContent(content, decorator);
|
|
}
|
|
}
|
|
|
|
export function createDecorator(type, component) {
|
|
const strategy = (block, callback, content) => {
|
|
return block.findEntityRanges((cmeta) => {
|
|
const entityKey = cmeta.getEntity();
|
|
return isDefined(entityKey) && (type === content.getEntity(entityKey).getType());
|
|
}, callback);
|
|
};
|
|
|
|
return new CompositeDecorator([
|
|
{"strategy": strategy, "component": component}
|
|
]);
|
|
}
|
|
|
|
function getSelectAllSelection(state) {
|
|
const content = state.getCurrentContent();
|
|
const firstBlock = content.getBlockMap().first();
|
|
const lastBlock = content.getBlockMap().last();
|
|
|
|
return new SelectionState({
|
|
"anchorKey": firstBlock.getKey(),
|
|
"anchorOffset": 0,
|
|
"focusKey": lastBlock.getKey(),
|
|
"focusOffset": lastBlock.getLength()
|
|
});
|
|
}
|
|
|
|
function getCursorInEndPosition(state) {
|
|
const content = state.getCurrentContent();
|
|
const lastBlock = content.getBlockMap().last();
|
|
|
|
return new SelectionState({
|
|
"anchorKey": lastBlock.getKey(),
|
|
"anchorOffset": lastBlock.getLength(),
|
|
"focusKey": lastBlock.getKey(),
|
|
"focusOffset": lastBlock.getLength()
|
|
});
|
|
}
|
|
|
|
export function selectAll(state) {
|
|
return EditorState.forceSelection(state, getSelectAllSelection(state));
|
|
}
|
|
|
|
function modifySelectedBlocks(contentState, selectionState, operation) {
|
|
var startKey = selectionState.getStartKey();
|
|
var endKey = selectionState.getEndKey();
|
|
var blockMap = contentState.getBlockMap();
|
|
|
|
var newBlocks = blockMap.toSeq().skipUntil(function (_, k) {
|
|
return k === startKey;
|
|
}).takeUntil(function (_, k) {
|
|
return k === endKey;
|
|
}).concat(Map([[endKey, blockMap.get(endKey)]])).map(operation);
|
|
|
|
return contentState.merge({
|
|
"blockMap": blockMap.merge(newBlocks),
|
|
"selectionBefore": selectionState,
|
|
"selectionAfter": selectionState
|
|
});
|
|
}
|
|
|
|
export function updateCurrentBlockData(state, attrs) {
|
|
const selection = state.getSelection();
|
|
let content = state.getCurrentContent();
|
|
|
|
content = modifySelectedBlocks(content, selection, (block) => {
|
|
return mergeBlockData(block, attrs);
|
|
});
|
|
|
|
return EditorState.push(state, content, "change-block-data");
|
|
}
|
|
|
|
function addStylesToOverride(styles, other) {
|
|
let result = styles;
|
|
|
|
for (let style of other) {
|
|
const [p, k, v] = style.split("$$$");
|
|
const prefix = [p, k, ""].join("$$$");
|
|
|
|
const curValue = result.find((it) => it.startsWith(prefix))
|
|
if (curValue) {
|
|
result = result.remove(curValue);
|
|
}
|
|
result = result.add(style);
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function applyInlineStyle(state, styles) {
|
|
const userSelection = state.getSelection();
|
|
let selection = userSelection;
|
|
let result = state;
|
|
|
|
if (selection.isCollapsed()) {
|
|
const currentOverride = state.getCurrentInlineStyle() || new OrderedSet();
|
|
const styleOverride = addStylesToOverride(currentOverride, styles)
|
|
return EditorState.setInlineStyleOverride(state, styleOverride);
|
|
}
|
|
|
|
let content = null;
|
|
|
|
for (let style of styles) {
|
|
const [p, k, v] = style.split("$$$");
|
|
const prefix = [p, k, ""].join("$$$");
|
|
|
|
content = result.getCurrentContent();
|
|
content = removeInlineStylePrefix(content, selection, prefix);
|
|
|
|
if (v !== "z:null") {
|
|
content = Modifier.applyInlineStyle(content, selection, style);
|
|
}
|
|
|
|
result = EditorState.push(result, content, "change-inline-style");
|
|
}
|
|
|
|
return EditorState.acceptSelection(result, userSelection);
|
|
}
|
|
|
|
export function splitBlockPreservingData(state) {
|
|
let content = state.getCurrentContent();
|
|
const selection = state.getSelection();
|
|
|
|
content = Modifier.splitBlock(content, selection);
|
|
|
|
const startKey = content.selectionBefore.getStartKey();
|
|
const block = content.blockMap.get(startKey);
|
|
const blockData = (block && block.getData()) || new Map();
|
|
const blockKey = content.selectionAfter.getStartKey();
|
|
const blockMap = content.blockMap.update(blockKey, (b) => {
|
|
return b.set("data", blockData);
|
|
});
|
|
|
|
content = content.set("blockMap", blockMap);
|
|
|
|
return EditorState.push(state, content, "split-block");
|
|
}
|
|
|
|
export function addBlurSelectionEntity(state) {
|
|
let content = state.getCurrentContent(state);
|
|
const selection = state.getSelection();
|
|
|
|
content = content.createEntity("PENPOT_SELECTION", "MUTABLE");
|
|
const entityKey = content.getLastCreatedEntityKey();
|
|
|
|
content = Modifier.applyEntity(content, selection, entityKey);
|
|
return EditorState.push(state, content, "apply-entity");
|
|
}
|
|
|
|
export function removeBlurSelectionEntity(state) {
|
|
const selectionAll = getSelectAllSelection(state);
|
|
const selection = state.getSelection();
|
|
|
|
let content = state.getCurrentContent();
|
|
content = Modifier.applyEntity(content, selectionAll, null);
|
|
|
|
state = EditorState.push(state, content, "apply-entity");
|
|
state = EditorState.forceSelection(state, selection);
|
|
|
|
return state;
|
|
}
|
|
|
|
export function getCurrentBlock(state) {
|
|
const content = state.getCurrentContent();
|
|
const selection = state.getSelection();
|
|
const startKey = selection.getStartKey();
|
|
return content.getBlockForKey(startKey);
|
|
}
|
|
|
|
export function getCurrentEntityKey(state) {
|
|
const block = getCurrentBlock(state);
|
|
const selection = state.getSelection();
|
|
const startOffset = selection.getStartOffset();
|
|
return block.getEntityAt(startOffset);
|
|
}
|
|
|
|
export function removeInlineStylePrefix(contentState, selectionState, stylePrefix) {
|
|
const startKey = selectionState.getStartKey();
|
|
const startOffset = selectionState.getStartOffset();
|
|
const endKey = selectionState.getEndKey();
|
|
const endOffset = selectionState.getEndOffset();
|
|
|
|
return modifySelectedBlocks(contentState, selectionState, (block, blockKey) => {
|
|
let sliceStart;
|
|
let sliceEnd;
|
|
|
|
if (startKey === endKey) {
|
|
sliceStart = startOffset;
|
|
sliceEnd = endOffset;
|
|
} else {
|
|
sliceStart = blockKey === startKey ? startOffset : 0;
|
|
sliceEnd = blockKey === endKey ? endOffset : block.getLength();
|
|
}
|
|
|
|
let chars = block.getCharacterList();
|
|
let current;
|
|
|
|
while (sliceStart < sliceEnd) {
|
|
current = chars.get(sliceStart);
|
|
current = current.set("style", current.getStyle().filter((s) => !s.startsWith(stylePrefix)))
|
|
chars = chars.set(sliceStart, CharacterMetadata.create(current));
|
|
|
|
sliceStart++;
|
|
}
|
|
|
|
return block.set("characterList", chars);
|
|
});
|
|
}
|
|
|
|
export function cursorToEnd(state) {
|
|
const newSelection = getCursorInEndPosition(state);
|
|
const selection = state.getSelection();
|
|
|
|
let content = state.getCurrentContent();
|
|
content = Modifier.applyEntity(content, newSelection, null);
|
|
|
|
state = EditorState.forceSelection(state, newSelection);
|
|
state = EditorState.push(state, content, "apply-entity");
|
|
|
|
return state;
|
|
}
|
|
|
|
export function isCurrentEmpty(state) {
|
|
const selection = state.getSelection();
|
|
|
|
if (!selection.isCollapsed()) {
|
|
return false;
|
|
}
|
|
|
|
const blockKey = selection.getStartKey();
|
|
const content = state.getCurrentContent();
|
|
|
|
const block = content.getBlockForKey(blockKey);
|
|
|
|
return block.getText() === "";
|
|
}
|
|
|
|
/*
|
|
Returns the block keys between a selection
|
|
*/
|
|
export function getSelectedBlocks(state) {
|
|
const selection = state.getSelection();
|
|
const startKey = selection.getStartKey();
|
|
const endKey = selection.getEndKey();
|
|
const content = state.getCurrentContent();
|
|
const result = [ startKey ];
|
|
|
|
let currentKey = startKey;
|
|
|
|
while (currentKey !== endKey) {
|
|
const currentBlock = content.getBlockAfter(currentKey);
|
|
currentKey = currentBlock.getKey();
|
|
result.push(currentKey);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function getBlockContent(state, blockKey) {
|
|
const content = state.getCurrentContent();
|
|
const block = content.getBlockForKey(blockKey);
|
|
return block.getText();
|
|
}
|
|
|
|
export function getBlockData(state, blockKey) {
|
|
const content = state.getCurrentContent();
|
|
const block = content.getBlockForKey(blockKey);
|
|
return block && block.getData().toJS();
|
|
}
|
|
|
|
export function updateBlockData(state, blockKey, data) {
|
|
const userSelection = state.getSelection();
|
|
const inlineStyleOverride = state.getInlineStyleOverride();
|
|
const content = state.getCurrentContent();
|
|
const block = content.getBlockForKey(blockKey);
|
|
const newBlock = mergeBlockData(block, data);
|
|
if (!newBlock) return state;
|
|
|
|
const blockData = newBlock.getData();
|
|
|
|
const newContent = Modifier.setBlockData(
|
|
state.getCurrentContent(),
|
|
SelectionState.createEmpty(blockKey),
|
|
blockData
|
|
);
|
|
|
|
let result = EditorState.push(state, newContent, 'change-block-data');
|
|
result = EditorState.acceptSelection(result, userSelection);
|
|
result = EditorState.setInlineStyleOverride(result, inlineStyleOverride);
|
|
return result;
|
|
}
|
|
|
|
export function getSelection(state) {
|
|
return state.getSelection();
|
|
}
|
|
|
|
export function setSelection(state, selection) {
|
|
return EditorState.acceptSelection(state, selection);
|
|
}
|
|
|
|
export function selectBlock(state, blockKey) {
|
|
const block = state.getCurrentContent().getBlockForKey(blockKey);
|
|
const length = block.getText().length;
|
|
const selection = SelectionState.createEmpty(blockKey).merge({
|
|
focusOffset: length
|
|
});
|
|
return EditorState.acceptSelection(state, selection);
|
|
}
|
|
|
|
export function getInlineStyle(state, blockKey, offset) {
|
|
const content = state.getCurrentContent();
|
|
const block = content.getBlockForKey(blockKey);
|
|
return block.getInlineStyleAt(offset).toJS();
|
|
}
|
|
|
|
const NEWLINE_REGEX = /\r\n?|\n/g;
|
|
|
|
function splitTextIntoTextBlocks(text) {
|
|
if (text == null) {
|
|
return [];
|
|
}
|
|
return text.split(NEWLINE_REGEX);
|
|
}
|
|
|
|
export function insertText(state, text, attrs, inlineStyles) {
|
|
const blocks = splitTextIntoTextBlocks(text);
|
|
|
|
if (blocks.length === 0) {
|
|
return state;
|
|
}
|
|
|
|
const character = CharacterMetadata.create({style: OrderedSet(inlineStyles)});
|
|
|
|
let blockArray = DraftPasteProcessor.processText(
|
|
blocks,
|
|
character,
|
|
"unstyled",
|
|
);
|
|
|
|
blockArray = blockArray.map((b) => {
|
|
return mergeBlockData(b, attrs);
|
|
});
|
|
|
|
const fragment = BlockMapBuilder.createFromArray(blockArray);
|
|
const content = state.getCurrentContent();
|
|
const selection = state.getSelection();
|
|
|
|
const newContent = Modifier.replaceWithFragment(
|
|
content,
|
|
selection,
|
|
fragment
|
|
);
|
|
|
|
const resultSelection = SelectionState.createEmpty(selection.getStartKey());
|
|
return EditorState.push(state, newContent, 'insert-fragment');
|
|
}
|
|
|
|
export function setInlineStyleOverride(state, inlineStyles) {
|
|
return EditorState.setInlineStyleOverride(state, inlineStyles);
|
|
}
|
|
|
|
export function selectionEquals(selection, other) {
|
|
return selection.getAnchorKey() === other.getAnchorKey() &&
|
|
selection.getAnchorOffset() === other.getAnchorOffset() &&
|
|
selection.getFocusKey() === other.getFocusKey() &&
|
|
selection.getFocusOffset() === other.getFocusOffset() &&
|
|
selection.getIsBackward() === other.getIsBackward();
|
|
}
|