Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,11 @@ export class SuperToolbar extends EventEmitter {
if (commandState?.value != null) item.activate({ styleId: commandState.value });
else item.label.value = this.config.texts?.formatText || 'Format text';
},
copyFormat: () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For copy-format item, in the headless state, the active state is not calculated, but only a disabled state.

packages/super-editor/src/headless-toolbar/toolbar-registry.ts

'copy-format': {
      id: 'copy-format',
      directCommandName: 'copyFormat',
      state: createDisabledStateDeriver(),
},

Need to create createCopyFormatStateDeriver in the headless toolbar and calculate for it not only the disabled state but also the active state.

Based on this check.
const hasStoredFormat = Boolean(this.activeEditor?.storage?.formatCommands?.storedStyle);

const hasStoredFormat = Boolean(this.activeEditor?.storage?.formatCommands?.storedStyle);
if (hasStoredFormat || commandState?.active) item.activate();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commandState?.active - I think this is dead code.

else item.deactivate();
},
list: () => {
if (commandState?.active) {
item.activate();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Extension } from '@core/Extension.js';
import { getMarksFromSelection } from '@core/helpers/getMarksFromSelection.js';
import { toggleMarkCascade } from '@core/commands/toggleMarkCascade.js';

const FORMAT_PAINTER_DOUBLE_CLICK_MS = 500;
const FORMAT_PAINTER_UI_SELECTOR =
'[data-editor-ui-surface], .toolbar-dropdown-menu, .sd-toolbar-dropdown-menu, .sd-tooltip-content';

/**
* Stored format style
* @typedef {Object} StoredStyle
Expand Down Expand Up @@ -36,6 +40,12 @@ export const FormatCommands = Extension.create({
* @type {StoredStyle[]|null}
*/
storedStyle: null,
sourceSelection: null,
persistent: false,
lastCopyFormatClickAt: 0,
releaseCleanup: null,
pointerSelecting: false,
keyboardSelecting: false,
};
},

Expand Down Expand Up @@ -86,75 +96,216 @@ export const FormatCommands = Extension.create({
* @category Command
* @example
* editor.commands.copyFormat()
* @note Works like format painter - first click copies, second click applies
* @note Works like format painter: click copies for one target selection; double-click keeps it active
*/
copyFormat:
() =>
({ chain }) => {
// If we don't have a saved style, save the current one
const currentSelection = getSelectionRange(this.editor.state);

if (!this.storage.storedStyle) {
const marks = getMarksFromSelection(this.editor.state, this.editor);
this.storage.storedStyle = marks;
this.storage.sourceSelection = currentSelection;
this.storage.persistent = false;
this.storage.lastCopyFormatClickAt = Date.now();
armFormatPainterRelease({ storage: this.storage, editor: this.editor });
return true;
}

if (this.storage.persistent) {
clearFormatPainterStorage(this.storage);
return true;
}

const clickedSourceAgain = isSameSelection(currentSelection, this.storage.sourceSelection);
const isDoubleClick =
clickedSourceAgain && Date.now() - this.storage.lastCopyFormatClickAt <= FORMAT_PAINTER_DOUBLE_CLICK_MS;

if (isDoubleClick && !this.storage.persistent) {
this.storage.persistent = true;
this.storage.lastCopyFormatClickAt = 0;
return true;
}

// Special case: if there are no stored marks, but this is still an apply action
// We just clear the format
if (!this.storage.storedStyle.length) {
this.storage.storedStyle = null;
return chain().clearFormat().run();
if (clickedSourceAgain) {
clearFormatPainterStorage(this.storage);
return true;
}

// If we do have a stored style, apply it
const storedMarks = this.storage.storedStyle;
const processedMarks = [];
storedMarks.forEach((mark) => {
const { type, attrs } = mark;
const { name } = type;

if (name === 'textStyle') {
Object.keys(attrs).forEach((key) => {
if (!attrs[key]) return;
const attributes = {};
attributes[key] = attrs[key];
processedMarks.push({ name: key, attrs: attributes });
});
} else {
processedMarks.push({ name, attrs });
}
});

const marksToCommands = {
bold: ['setBold', 'unsetBold'],
italic: ['setItalic', 'unsetItalic'],
underline: ['setUnderline', 'unsetUnderline'],
color: ['setColor', 'setColor', null],
fontSize: ['setFontSize', 'unsetFontSize'],
fontFamily: ['setFontFamily', 'unsetFontFamily'],
};

// Apply marks present, clear ones that are not, by chaining commands
let result = chain();
Object.keys(marksToCommands).forEach((key) => {
const [setCommand, unsetCommand, defaultParam] = marksToCommands[key];
const markToApply = processedMarks.find((mark) => mark.name === key);
const hasEmptyAttrs = markToApply?.attrs && markToApply?.attrs[key];

let cmd = {};
if (!markToApply && !hasEmptyAttrs) cmd = { command: unsetCommand, argument: defaultParam };
else cmd = { command: setCommand, argument: markToApply.attrs[key] || defaultParam };
result = result[cmd.command](cmd.argument);
});

this.storage.storedStyle = null;
return result;
return applyStoredFormat({ chain, storage: this.storage });
},

/**
* Apply the stored format painter style to the current selection.
* @category Command
* @example
* editor.commands.applyStoredFormat()
*/
applyStoredFormat:
() =>
({ chain }) => {
return applyStoredFormat({ chain, storage: this.storage });
},
};
},

onSelectionUpdate({ editor }) {
const { storedStyle, sourceSelection } = this.storage;
if (!storedStyle) return;

const currentSelection = getSelectionRange(editor.state);
if (editor.state.selection.empty || isSameSelection(currentSelection, sourceSelection)) return;
if (this.storage.pointerSelecting || this.storage.keyboardSelecting) return;

editor.commands.applyStoredFormat();
},

onDestroy() {
clearFormatPainterStorage(this.storage);
},

addShortcuts() {
return {
'Mod-Alt-c': () => this.editor.commands.clearFormat(),
};
},
});

function getSelectionRange(state) {
const { from, to } = state.selection;
return { from, to };
}

function isSameSelection(selection, otherSelection) {
if (!selection || !otherSelection) return false;
return selection.from === otherSelection.from && selection.to === otherSelection.to;
}

function clearFormatPainterStorage(storage) {
storage.releaseCleanup?.();
storage.storedStyle = null;
storage.sourceSelection = null;
storage.persistent = false;
storage.lastCopyFormatClickAt = 0;
storage.releaseCleanup = null;
storage.pointerSelecting = false;
storage.keyboardSelecting = false;
}

function armFormatPainterRelease({ storage, editor }) {
if (storage.releaseCleanup) return;
if (typeof document === 'undefined' || !document?.addEventListener) return;

const pointerDownEventName = typeof PointerEvent === 'undefined' ? 'mousedown' : 'pointerdown';
const pointerUpEventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup';
const isToolbarEvent = (event) => event?.target?.closest?.(FORMAT_PAINTER_UI_SELECTOR);

const applyIfTargetSelected = () => {
if (!storage.storedStyle) return;
const selection = editor.state.selection;
const currentSelection = getSelectionRange(editor.state);
if (selection.empty || isSameSelection(currentSelection, storage.sourceSelection)) return;

editor.commands.applyStoredFormat();
};

const handlePointerDown = (event) => {
if (isToolbarEvent(event)) {
storage.pointerSelecting = false;
return;
}
storage.pointerSelecting = true;
};

const handleRelease = (event) => {
if (isToolbarEvent(event)) {
storage.pointerSelecting = false;
return;
}
storage.pointerSelecting = false;
applyIfTargetSelected();
};

const handleKeyDown = (event) => {
if (isToolbarEvent(event)) return;
if (isFormatPainterSelectionKey(event)) storage.keyboardSelecting = true;
};

const handleKeyUp = () => {
if (!storage.keyboardSelecting) return;
storage.keyboardSelecting = false;
applyIfTargetSelected();
};

document.addEventListener(pointerDownEventName, handlePointerDown, true);
document.addEventListener(pointerUpEventName, handleRelease, true);
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('keyup', handleKeyUp, true);
storage.releaseCleanup = () => {
document.removeEventListener(pointerDownEventName, handlePointerDown, true);
document.removeEventListener(pointerUpEventName, handleRelease, true);
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('keyup', handleKeyUp, true);
};
}

function isFormatPainterSelectionKey(event) {
if (!event?.shiftKey) return false;
return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key);
}

function applyStoredFormat({ chain, storage }) {
if (!storage.storedStyle) return false;

const shouldStayActive = storage.persistent;
try {
if (!storage.storedStyle.length) {
if (!shouldStayActive) clearFormatPainterStorage(storage);
return chain().clearFormat().run();
}

const storedMarks = storage.storedStyle;
const processedMarks = [];
storedMarks.forEach((mark) => {
const { type, attrs } = mark;
const { name } = type;

if (name === 'textStyle') {
Object.keys(attrs).forEach((key) => {
if (!attrs[key]) return;
const attributes = {};
attributes[key] = attrs[key];
processedMarks.push({ name: key, attrs: attributes });
});
} else {
processedMarks.push({ name, attrs });
}
});

const marksToCommands = {
bold: ['setBold', 'unsetBold'],
italic: ['setItalic', 'unsetItalic'],
underline: ['setUnderline', 'unsetUnderline'],
color: ['setColor', 'setColor', null],
fontSize: ['setFontSize', 'unsetFontSize'],
fontFamily: ['setFontFamily', 'unsetFontFamily'],
};

let result = chain();
Object.keys(marksToCommands).forEach((key) => {
const [setCommand, unsetCommand, defaultParam] = marksToCommands[key];
const markToApply = processedMarks.find((mark) => mark.name === key);
const hasEmptyAttrs = markToApply?.attrs && markToApply?.attrs[key];

let cmd = {};
if (!markToApply && !hasEmptyAttrs) cmd = { command: unsetCommand, argument: defaultParam };
else cmd = { command: setCommand, argument: markToApply.attrs[key] || defaultParam };
result = result[cmd.command](cmd.argument);
});

return result;
} finally {
if (!shouldStayActive) clearFormatPainterStorage(storage);
}
}
Loading
Loading