Skip to content
This repository has been archived by the owner on Dec 15, 2023. It is now read-only.

Commit

Permalink
feat(rich-text-editor): link extension (#991)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikaDialpad authored and juliodialpad committed Jun 9, 2023
1 parent 2427f39 commit d88e704
Show file tree
Hide file tree
Showing 9 changed files with 516 additions and 8 deletions.
127 changes: 127 additions & 0 deletions common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,128 @@ export function isOutOfViewPort (element) {
isOut.all = Object.values(isOut).every(val => val);
return isOut;
}

// match valid characters for a domain name followed by a dot, e.g. "dialpad."
const domainNameRegex = /(?:(?:[^\s!@#$%^&*()_=+[\]{}\\|;:'",.<>/?]+)\.)/;

// match valid TLDs for a hostname (outdated list from ~2017)
const tldRegerx = new RegExp(
'(?:' +
'com|ru|org|net|de|jp|uk|br|it|pl|fr|in|au|ir|info|nl|cn|es|cz|kr|ca|eu|ua|co|gr|' +
'za|ro|biz|ch|se|tw|mx|vn|hu|be|tr|at|dk|tv|me|ar|sk|no|us|fi|id|cl|xyz|io|pt|by|' +
'il|ie|nz|kz|hk|lt|cc|my|sg|club|bg|edu|рф|pk|su|top|th|hr|rs|pe|pro|si|az|lv|pw|' +
'ae|ph|online|ng|ee|ws|ve|cat' +
')',
);

// match valid IPv4 addresses, e.g. "192.158.1.38"
const ipv4Regex = new RegExp(
'(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}' +
'(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])',
);

// match hostnames OR IPv4 addresses, e.g. "dialpad.com" or "192.158.1.38"
const hostnameOrIpRegex = new RegExp(
'(?:' +
[
[
domainNameRegex.source,
tldRegerx.source,
].join('+'),
ipv4Regex.source,
].join('|') +
')',
);

// match URL paths, e.g. "/news"
const urlPathRegex = /(?:(?:[;/][^#?<>\s]*)?)/;

// match URL queries and fragments, e.g. "?cache=1&new=true" or "#heading1"
const urlQueryOrFragmentRegex = /(?:(?:\?[^#<>\s]+)?(?:#[^<>\s]+)?)/;

// match complete hostnames or IPv4 addresses without a protocol and with optional
// URL paths, queries and fragments e.g. "dialpad.com/news?cache=1#heading1"
const urlWithoutProtocolRegex = new RegExp(
'\\b' +
[
hostnameOrIpRegex.source,
urlPathRegex.source,
urlQueryOrFragmentRegex.source,
'(?!\\w)',
].join('+'),
);

// match complete hostnames with protocols and optional URL paths, queries and fragments,
// e.g. "ws://localhost:9010" or "https://dialpad.com/news?cache=1#heading1"
const urlWithProtocolRegex = /\b[a-z\d.-]+:\/\/[^<>\s]+/;

// match email addresses with an optional "mailto:" prefix and URL queries, e.g.
// "hey@dialpad.com" or "mailto:hey@dialpad.com?subject=Hi&body=Hey%20there"
const emailAddressRegex = new RegExp(
'(?:mailto:)?' +
'[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' +
[
hostnameOrIpRegex.source,
urlQueryOrFragmentRegex.source,
].join('+') +
'(?!\\w)',
);

/**
* Match phone numbers, e.g. "765-8813", "(778) 765-8813" or "+17787658813".
* @param {number} minLength
* @param {number} maxLength
* @returns {RegExp}
*/
export function getPhoneNumberRegex (minLength = 7, maxLength = 15) {
return new RegExp(
'(?:^|(?<=\\W))(?![\\s\\-])\\+?(?:[0-9()\\- \\t]' +
`{${minLength},${maxLength}}` +
')(?=\\b)(?=\\W(?=\\W|$)|\\s|$)',
);
}

const phoneNumberRegex = getPhoneNumberRegex();

// match all link types
export const linkRegex = new RegExp(
[
urlWithoutProtocolRegex.source,
urlWithProtocolRegex.source,
emailAddressRegex.source,
phoneNumberRegex.source,
].join('|'),
'gi',
);

/**
* Check if a string is a phone number. Validates only exact matches.
* @param {string} string
* @returns {boolean}
*/
export function isPhoneNumber (string) {
return phoneNumberRegex.exec(string)?.[0] === string;
}

/**
* Check if a string is an URL. Validates only exact matches.
* @param {string} string
* @returns {boolean}
*/
export function isURL (string) {
return urlWithoutProtocolRegex.exec(string)?.[0] === string ||
urlWithProtocolRegex.exec(string)?.[0] === string;
}

/**
* Check if a string is an email address. Validates only exact matches.
* @param {string} string
* @returns {boolean}
*/
export function isEmailAddress (string) {
return emailAddressRegex.exec(string)?.[0] === string;
}

export default {
getUniqueString,
getRandomElement,
Expand All @@ -201,4 +323,9 @@ export default {
kebabCaseToPascalCase,
debounce,
isOutOfViewPort,
getPhoneNumberRegex,
linkRegex,
isEmailAddress,
isPhoneNumber,
isURL,
};
87 changes: 87 additions & 0 deletions components/rich_text_editor/extensions/link/autolink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
} from '@tiptap/core';
import {
Plugin,
PluginKey,
} from '@tiptap/pm/state';
import {
addMarks,
removeMarks,
} from './utils';

/**
* Plugin to automatically add links into content.
*/
export function autolink (options) {
// Flag to see if we've loaded this plugin once already. This is used to run
// the initial content through the plugin if the editor was mounted with some.
let hasInitialized = false;

return new Plugin({
key: new PluginKey('autolink'),

appendTransaction: (transactions, oldState, newState) => {
const contentChanged = transactions.some(tr => tr.docChanged) &&
!oldState.doc.eq(newState.doc);

// Every interaction with the editor is a transaction, but we only care
// about the ones with content changes.
if (hasInitialized && !contentChanged) {
return;
}

// The original transaction that we're manipulating.
const { tr } = newState;

// Text content after the original transaction.
const { textContent } = newState.doc;

// When the editor is initialized we want to add links to it.
if (!hasInitialized) {
addMarks(textContent, 0, 0, textContent.length, tr, options.type);
}

hasInitialized = true;

// The transformed state of the document.
const transform = combineTransactionSteps(
oldState.doc,
[...transactions],
);

// All the changes within the document.
const changes = getChangedRanges(transform);

changes.forEach(({ oldRange, newRange }) => {
// Remove all link marks in the changed range since we'll add them
// right back if they're still valid links.
removeMarks(newRange, newState.doc, tr, options.type);

// Find all paragraphs (Textblocks) that were affected since we want to
// handle matches in each paragraph separately.
const paragraphs = findChildrenInRange(
newState.doc,
newRange,
node => node.isTextblock,
);

paragraphs.forEach(({ node, pos }) => {
addMarks(
node.textContent,
pos,
oldRange.from,
newRange.to,
tr,
options.type,
);
});
});

// Return the modified transaction or the changes above wont have effect.
return tr;
},
});
}
5 changes: 5 additions & 0 deletions components/rich_text_editor/extensions/link/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Link } from './link';

export * from './link';

export default Link;
38 changes: 38 additions & 0 deletions components/rich_text_editor/extensions/link/link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
mergeAttributes,
Mark,
} from '@tiptap/core';
import { autolink } from './autolink';

const defaultAttributes = {
class: 'd-link d-c-text d-d-inline-block',
rel: 'noopener noreferrer nofollow',
};

// This is the actual extension code, which is mostly showing that all the
// functionality comes from the ProseMirror plugin.
export const Link = Mark.create({
name: 'Link',

renderHTML ({ HTMLAttributes }) {
return [
'a',
mergeAttributes(
this.options.HTMLAttributes,
HTMLAttributes,
defaultAttributes,
),
0,
];
},

renderText ({ node }) {
return node.attrs.text;
},

addProseMirrorPlugins () {
return [
autolink({ type: this.type }),
];
},
});
Loading

0 comments on commit d88e704

Please sign in to comment.