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: 2 additions & 0 deletions demo/scripts/controlsV2/mainPane/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
AnnouncePlugin,
AutoFormatPlugin,
CustomReplacePlugin,
DragAndDropPlugin,
EditPlugin,
HiddenPropertyPlugin,
HyperlinkPlugin,
Expand Down Expand Up @@ -577,6 +578,7 @@ export class MainPane extends React.Component<{}, MainPaneState> {
}),
pluginList.touch && new TouchPlugin(),
pluginList.announce && new AnnouncePlugin(),
pluginList.dragAndDrop && new DragAndDropPlugin(),
].filter(x => !!x);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const initialState: OptionState = {
hiddenProperty: true,
touch: true,
announce: true,
dragAndDrop: true,
},
defaultFormat: {
fontFamily: 'Calibri',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BuildInPluginList {
hiddenProperty: boolean;
touch: boolean;
announce: boolean;
dragAndDrop: boolean;
}

export interface OptionState {
Expand Down
1 change: 1 addition & 0 deletions demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
{this.renderPluginItem('hiddenProperty', 'Hidden Property')}
{this.renderPluginItem('touch', 'Touch')}
{this.renderPluginItem('announce', 'Announce')}
{this.renderPluginItem('dragAndDrop', 'DragAndDrop')}
</tbody>
</table>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { OptionState } from '../OptionState';
import { WatermarkCode } from './WatermarkCode';

import {
DragAndDropPluginCode,
EditPluginCode,
PastePluginCode,
TableEditPluginCode,
Expand Down Expand Up @@ -45,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase {
pluginList.watermark && new WatermarkCode(state.watermarkText),
pluginList.markdown && new MarkdownCode(state.markdownOptions),
pluginList.imageEditPlugin && new ImageEditPluginCode(),
pluginList.dragAndDrop && new DragAndDropPluginCode(),
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export class ImageEditPluginCode extends SimplePluginCode {
super('ImageEditPlugin');
}
}

export class DragAndDropPluginCode extends SimplePluginCode {
constructor() {
super('DragAndDropPlugin');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {

// 4. Drag and Drop event
dragstart: { beforeDispatch: this.onDragStart },
drop: { beforeDispatch: this.onDrop },
drop: { beforeDispatch: (event: DragEvent) => this.onDrop(event) },

// 5. Pointer event
pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) },
Expand Down Expand Up @@ -137,17 +137,23 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
}
};

private onDrop = () => {
const doc = this.editor?.getDocument();

doc?.defaultView?.requestAnimationFrame(() => {
if (this.editor) {
this.editor.takeSnapshot();
this.editor.triggerEvent('contentChanged', {
source: ChangeSource.Drop,
private onDrop = (e: DragEvent) => {
if (this.editor) {
const beforeDropEvent = this.editor.triggerEvent('beforeDrop', {
rawEvent: e,
});
if (!beforeDropEvent?.rawEvent.defaultPrevented) {
const doc = this.editor.getDocument();
doc?.defaultView?.requestAnimationFrame(() => {
if (this.editor) {
this.editor.takeSnapshot();
this.editor.triggerEvent('contentChanged', {
source: ChangeSource.Drop,
});
}
});
}
});
}
};

private onScroll = (e: Event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro
expect(triggerEventSpy).toHaveBeenCalled();
});


it('verify input event for non-character value', () => {
spyOn(eventUtils, 'isCharacterValue').and.returnValue(false);
const stopPropagation = jasmine.createSpy();
Expand Down Expand Up @@ -282,7 +281,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro
expect(stopPropagation).toHaveBeenCalled();
expect(triggerEventSpy).toHaveBeenCalled();
});

});

describe('DOMEventPlugin handle mouse down and mouse up event', () => {
Expand Down Expand Up @@ -553,18 +551,50 @@ describe('DOMEventPlugin handle other event', () => {
it('Trigger onDrop event', () => {
const takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
editor.takeSnapshot = takeSnapshotSpy;
const mockedEvent = {
dataTransfer: {
getData: () => '',
},
defaultPrevented: false,
} as any;

eventMap.drop.beforeDispatch();
triggerEvent.and.returnValue({ rawEvent: mockedEvent });

eventMap.drop.beforeDispatch(mockedEvent);
expect(plugin.getState()).toEqual({
isInIME: false,
scrollContainer: scrollContainer,
mouseDownX: null,
mouseDownY: null,
mouseUpEventListerAdded: false,
});
expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', {
rawEvent: mockedEvent,
});
expect(takeSnapshotSpy).toHaveBeenCalledWith();
expect(triggerEvent).toHaveBeenCalledWith('contentChanged', {
source: ChangeSource.Drop,
});
});

it('Trigger onDrop event with defaultPrevented', () => {
const takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
editor.takeSnapshot = takeSnapshotSpy;
const mockedEvent = {
dataTransfer: {
getData: () => '',
},
defaultPrevented: true,
} as any;

triggerEvent.and.returnValue({ rawEvent: mockedEvent });

eventMap.drop.beforeDispatch(mockedEvent);

expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', {
rawEvent: mockedEvent,
});
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(triggerEvent).not.toHaveBeenCalledWith('contentChanged', jasmine.anything());
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
const linkRegex = /(\[([^\[]+)\]\((https?:\/\/[^\)]+)\))|(\!\[([^\[]+)\]\((https?:\/\/[^\)]+)\))/g;
// Matches markdown links and images in a string.
// Group 1 (full link): [text](url) e.g. [Click here](https://example.com)
// Group 2: link text e.g. "Click here"
// Group 3: link url e.g. "https://example.com"
// Group 4 (full image): ![alt](url) e.g. ![Logo](https://example.com/logo.png)
// Group 5: alt text e.g. "Logo"
// Group 6: image url e.g. "https://example.com/logo.png"
const linkRegex = /(\[([^\[]+)\]\(([^\)]+)\))|(\!\[([^\[]+)\]\(([^\)]+)\))/g;

/**
* @internal
Expand All @@ -10,14 +17,38 @@ interface MarkdownSegment {
}

const isValidUrl = (url: string) => {
try {
new URL(url);
if (!url) {
return false;
}

// Accept common non-http schemes and relative paths
if (
url.startsWith('data:') ||
url.startsWith('blob:') ||
url.startsWith('/') ||
url.startsWith('./') ||
url.startsWith('../')
) {
return true;
}

try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch (_) {
return false;
}
};

function pushText(result: MarkdownSegment[], text: string) {
const last = result[result.length - 1];
if (last && last.type === 'text') {
last.text += text;
} else {
result.push({ type: 'text', text, url: '' });
}
}

/**
* @internal
*/
Expand All @@ -28,28 +59,28 @@ export function splitParagraphSegments(text: string): MarkdownSegment[] {

while ((match = linkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ type: 'text', text: text.slice(lastIndex, match.index), url: '' });
pushText(result, text.slice(lastIndex, match.index));
}

if (match[2] && match[3]) {
result.push(
isValidUrl(match[3])
? { type: 'link', text: match[2], url: match[3] }
: { type: 'text', text: match[0], url: '' }
);
if (isValidUrl(match[3])) {
result.push({ type: 'link', text: match[2], url: match[3] });
} else {
pushText(result, match[0]);
}
} else if (match[5] && match[6]) {
result.push(
isValidUrl(match[6])
? { type: 'image', text: match[5], url: match[6] }
: { type: 'text', text: match[0], url: '' }
);
if (isValidUrl(match[6])) {
result.push({ type: 'image', text: match[5], url: match[6] });
} else {
pushText(result, match[0]);
}
}

lastIndex = linkRegex.lastIndex;
}

if (lastIndex < text.length) {
result.push({ type: 'text', text: text.slice(lastIndex), url: '' });
pushText(result, text.slice(lastIndex));
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,110 @@ describe('splitLinksAndImages', () => {
]
);
});

it('should treat invalid link as text but still render valid image', () => {
runTest('[link](ht3tps://www.example.com) and ![image](https://www.example.com)', [
{ text: '[link](ht3tps://www.example.com) and ', type: 'text', url: '' },
{ text: 'image', type: 'image', url: 'https://www.example.com' },
]);
});

it('should render valid link but treat invalid image as text', () => {
runTest('[link](https://www.example.com) and ![image](http3s://www.example.com)', [
{ text: 'link', type: 'link', url: 'https://www.example.com' },
{ text: ' and ![image](http3s://www.example.com)', type: 'text', url: '' },
]);
});

it('should accept data: URL for image', () => {
runTest('![image](data:image/png;base64,abc123)', [
{ text: 'image', type: 'image', url: 'data:image/png;base64,abc123' },
]);
});

it('should accept blob: URL for image', () => {
runTest('![image](blob:https://example.com/some-id)', [
{ text: 'image', type: 'image', url: 'blob:https://example.com/some-id' },
]);
});

it('should accept absolute path for link', () => {
runTest('[link](/path/to/page)', [{ text: 'link', type: 'link', url: '/path/to/page' }]);
});

it('should accept relative path with ./ for link', () => {
runTest('[link](./relative/path)', [
{ text: 'link', type: 'link', url: './relative/path' },
]);
});

it('should accept relative path with ../ for link', () => {
runTest('[link](../parent/path)', [{ text: 'link', type: 'link', url: '../parent/path' }]);
});

it('should handle text before and after a link', () => {
runTest('before [link](https://www.example.com) after', [
{ text: 'before ', type: 'text', url: '' },
{ text: 'link', type: 'link', url: 'https://www.example.com' },
{ text: ' after', type: 'text', url: '' },
]);
});

it('should accept http: URL', () => {
runTest('[link](http://www.example.com)', [
{ text: 'link', type: 'link', url: 'http://www.example.com' },
]);
});

it('should accept URL with query string and fragment', () => {
runTest('[link](https://www.example.com/page?q=1&r=2#section)', [
{
text: 'link',
type: 'link',
url: 'https://www.example.com/page?q=1&r=2#section',
},
]);
});

it('should handle two adjacent links with no text between', () => {
runTest('[first](https://www.example.com/1)[second](https://www.example.com/2)', [
{ text: 'first', type: 'link', url: 'https://www.example.com/1' },
{ text: 'second', type: 'link', url: 'https://www.example.com/2' },
]);
});

it('should treat a single invalid link as plain text', () => {
runTest('[link](ht3tps://www.example.com)', [
{ text: '[link](ht3tps://www.example.com)', type: 'text', url: '' },
]);
});

it('should treat a single invalid image as plain text', () => {
runTest('![image](http3s://www.example.com)', [
{ text: '![image](http3s://www.example.com)', type: 'text', url: '' },
]);
});

it('should treat partial markdown syntax as plain text', () => {
runTest('[not a link] and (not a url)', [
{ text: '[not a link] and (not a url)', type: 'text', url: '' },
]);
});

it('should accept relative path for image', () => {
runTest('![image](./images/photo.png)', [
{ text: 'image', type: 'image', url: './images/photo.png' },
]);
});

it('should handle multiple images in a row', () => {
runTest(
'![first](https://www.example.com/1.png) ![second](https://www.example.com/2.png)',
[
{ text: 'first', type: 'image', url: 'https://www.example.com/1.png' },
{ text: ' ', type: 'text', url: '' },
{ text: 'second', type: 'image', url: 'https://www.example.com/2.png' },
]
);
});
});
Loading
Loading