Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
241 changes: 241 additions & 0 deletions static/ckeditor/ckeditor/plugins/tweet_splitter/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
CKEDITOR.plugins.add('tweet_splitter', {
init: function(editor) {
editor.addContentsCss(this.path + 'styles.css');

editor.addCommand('insertTweetSplitter', {
exec: function(editor) {
const separator = CKEDITOR.dom.element.createFromHtml(
`<div class="tweet-separator" contenteditable="false">
<span class="tweet-separator-line"></span>
<span class="tweet-separator-text">Новый твит
<span class="tweet-separator-remove" title="Объединить твиты" role="button" tabindex="0" aria-label="Объединить твиты">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12M4 4L12 12" stroke="#666" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
</span>
<span class="tweet-separator-line"></span>
</div>`
);
editor.insertElement(separator);
scheduleTweetCountersUpdate(editor);;

const nativeDoc = editor.document.$;
if (!nativeDoc._tweetSplitterRemoveBound) {
nativeDoc._tweetSplitterRemoveBound = true;
nativeDoc.addEventListener('click', (ev) => {
const btn = ev.target.closest && ev.target.closest('.tweet-separator-remove');
if (!btn) return;
const sep = btn.closest('.tweet-separator');
if (sep) {
sep.remove();
scheduleTweetCountersUpdate(editor);
editor.focus();
}
});
nativeDoc.addEventListener('keydown', (ev) => {
if ((ev.key === 'Enter' || ev.key === ' ') && ev.target.classList && ev.target.classList.contains('tweet-separator-remove')) {
ev.preventDefault();
ev.target.click();
}
});
}
}
});

editor.ui.addButton('TweetSplitter', {
label: 'Разбить на твиты',
command: 'insertTweetSplitter',
toolbar: 'insert',
icon: 'horizontalrule',
});

editor.on('instanceReady', function() {
updateTweetCounters(editor);
});

editor.on('key', function() {
scheduleTweetCountersUpdate(editor);;
});

editor.on('afterCommandExec', function() {
scheduleTweetCountersUpdate(editor);;
});
Comment thread
mlv-dev marked this conversation as resolved.
editor.on('contentDom', function() {
editor.document.on('copy', function(evt) {
const e = evt.data.$; // нативный ClipboardEvent
e.preventDefault(); // отменяем стандартное копирование

let html = editor.getSelectedHtml(true);

// Удаляем div tweet-separator и tweet-char-counter целиком
html = html.replace(/<div class="tweet-(separator|char-counter)[^>]*>[\s\S]*?<\/div>/g, '');

// Убираем все теги, оставляем только текст
let text = html.replace(/<[^>]+>/g, '');

// Кладём своё
if (e.clipboardData && e.clipboardData.setData) {
e.clipboardData.setData('text/plain', text);
e.clipboardData.setData('text/html', text);
} else if (window.clipboardData && window.clipboardData.setData) {
// IE fallback
window.clipboardData.setData('Text', text);
}
});
Comment thread
mlv-dev marked this conversation as resolved.
});
}
});



const TWEET_LIMIT = 280;

function twitterLength(text) {
if (!text) return 0;

// Заменяем http/https ссылки и bare domains на 23 символа
const urlRegex = /\b((https?:\/\/[^\s]+)|([a-z0-9.-]+\.[a-z]{2,})(\/[^\s]*)?)/gi;
text = text.replace(urlRegex, 'x'.repeat(23));

// Эмодзи считаем за 2 символа
const emojiRegex = /[\p{Emoji_Presentation}\p{Emoji}\u200d]/gu;
text = text.replace(emojiRegex, 'xx');

return text.length;
}
Comment thread
mlv-dev marked this conversation as resolved.

const container = editor.container && editor.container.$;
const form = container && container.closest ? container.closest('form') : null;
if (form && !form._tweetSplitterBound) {
form._tweetSplitterBound = true;
form.addEventListener('submit', (e) => {
if (!validateTweets(editor)) {
e.preventDefault();
alert("❌ Нельзя сохранить: один или несколько твитов превышают 280 символов!");
}
});
}

Comment thread
mlv-dev marked this conversation as resolved.
Outdated
function validateTweets(editor) {
const editorData = editor.getData();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = editorData;

const nodes = Array.from(tempDiv.childNodes);
let currentTweet = [];
const tweets = [];

nodes.forEach(node => {
if (node.className === 'tweet-separator') {
tweets.push(currentTweet);
currentTweet = [];
} else {
currentTweet.push(node);
}
});
if (currentTweet.length) tweets.push(currentTweet);

// Проверяем длину
for (let tweet of tweets) {
const div = document.createElement('div');
tweet.forEach(n => div.appendChild(n.cloneNode(true)));
const text = div.textContent || div.innerText || '';
if (twitterLength(text.trim()) > TWEET_LIMIT) {
return false;
}
}
return true;
}
function scheduleTweetCountersUpdate(editor, delay = 120) {
if (editor._tweetCountersTimer) clearTimeout(editor._tweetCountersTimer);
editor._tweetCountersTimer = setTimeout(() => updateTweetCounters(editor), delay);
}

function updateTweetCounters(editor) {
const editorDom = editor.document.$;
// Удаляем старые элементы
editorDom.querySelectorAll('.tweet-numbering, .tweet-arrow, .tweet-char-counter').forEach(el => el.remove());

const content = editor.getData();
const tweetsHtml = content.split(/<div class="tweet-separator"[^>]*>[\s\S]*?<\/div>/g);
const separators = editorDom.querySelectorAll('.tweet-separator');

// Собираем все узлы редактора
let nodes = Array.from(editorDom.body.childNodes);
let tweetNodes = [];
let currentTweet = [];

// Разделяем узлы на твиты
nodes.forEach(node => {
if (node.className === 'tweet-separator') {
if (currentTweet.length) {
tweetNodes.push(currentTweet);
currentTweet = [];
}
} else {
currentTweet.push(node);
}
});
if (currentTweet.length) {
tweetNodes.push(currentTweet);
}

tweetNodes.forEach((tweet, index) => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = tweetsHtml[index] || '';
const text = tempDiv.textContent || tempDiv.innerText || "";
const textLength = twitterLength(text.trim());

const tweetNumbering = (tweetNodes.length > 1) ? `${index + 1}/${tweetNodes.length} ` : '';
const arrow = (index < tweetNodes.length - 1) ? ' ->' : '';
const tweetNumberingLength = tweetNumbering.length;
const arrowLength = arrow.length;
const finalLength = textLength + tweetNumberingLength + arrowLength;

// Находим первый и последний <p> для твита
const firstP = tweet.find(node => node.nodeName === 'P');
const lastP = tweet.slice().reverse().find(node => node.nodeName === 'P') || firstP;

// Счётчик
const counter = document.createElement('div');
counter.className = 'tweet-char-counter';
counter.setAttribute('contenteditable', 'false');
counter.innerText = `[${finalLength}/${TWEET_LIMIT}]`;
if (finalLength > TWEET_LIMIT) {
counter.style.color = 'red';
}

// Нумерация
if (tweetNumbering && firstP) {
const numbering = document.createElement('span');
numbering.className = 'tweet-numbering';
numbering.setAttribute('contenteditable', 'false');
numbering.innerText = tweetNumbering;
firstP.insertBefore(numbering, firstP.firstChild);
}

// Стрелка
if (arrow && lastP) {
// Убираем лишние <br /> в конце параграфа
while (lastP.lastChild && lastP.lastChild.nodeName === 'BR') {
lastP.removeChild(lastP.lastChild);
}

const arrowEl = document.createElement('span');
arrowEl.className = 'tweet-arrow';
arrowEl.setAttribute('contenteditable', 'false');
arrowEl.innerText = arrow;
lastP.appendChild(arrowEl);
}

// Вставка счётчика
if (index === 0) {
editorDom.body.insertBefore(counter, editorDom.body.firstChild);
} else if (separators[index - 1]) {
separators[index - 1].parentNode.insertBefore(counter, separators[index - 1].nextSibling);
} else {
editorDom.body.appendChild(counter);
}
});
}
81 changes: 81 additions & 0 deletions static/ckeditor/ckeditor/plugins/tweet_splitter/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
.tweet-numbering {
display: inline-block;
font-size: 13px;
color: #555;
font-weight: bold;
user-select: none;
margin-right: 5px;
}

.tweet-arrow {
display: inline-block;
font-size: 13px;
color: #555;
font-weight: bold;
user-select: none;
margin-left: 5px;
}

.tweet-char-counter {
float: right;
font-size: 13px;
color: #555;
font-weight: bold;
user-select: none;
padding: 2px 5px;
background-color: #f0f0f0;
border-radius: 4px;
margin-bottom: 5px;
}

.tweet-separator {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
cursor: default;
user-select: none;
}

.tweet-separator-line {
flex-grow: 1;
height: 1px;
background-color: #ccc;
}

.tweet-separator-text {
padding: 0 15px;
color: #999;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
display: flex;
align-items: center;
}

.tweet-separator-remove {
cursor: pointer;
display: inline-flex;
align-items: center;
line-height: 1;
user-select: none;
vertical-align: middle;
margin-left: 5px;
padding: 2px;
border-radius: 4px;
}

.tweet-separator-remove:hover {
background-color: #ddd;
}

.tweet-separator-remove:focus-visible {
outline: 2px solid #666;
outline-offset: 2px;
border-radius: 4px;
}

.tweet-separator-remove svg {
width: 16px;
height: 16px;
}