Last active
June 30, 2025 08:24
-
-
Save deadmann/015cf0a4511ec1cbdb01a6daf86b4337 to your computer and use it in GitHub Desktop.
This template-editor is razor based component that allow user to define template for sending SMS, email, with fixed template and dynamic data.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@* | |
Model Description: | |
// Expected Models: | |
1. string // The initial value to be passed to editor | |
2. dynamic object { | |
CurrentValue: string, // The initial value to be passed to editor | |
InputName: string // The custom name of form input field | |
} | |
USAGE: | |
// Top of Page: | |
@{ // For Typed Model | |
public class TemplateEditorModel | |
{ | |
public string InputName { get; set; } = null!; | |
public string? CurrentValue { get; set; }; | |
} | |
} | |
@{ | |
var scripts = new List<IHtmlContent>(); | |
var stringModel = "value"; | |
var typedModel = new TemplateEditorModel | |
{ | |
CurrentValue = "CustomValue", | |
InputName = "CustomInputName" | |
}; | |
} | |
// typed model | |
// We Need to manually pass `Script` as a reference, to be able to retrive it later, otherwise object created on partial (smaller object) will not return | |
// Data flows from: layout → view → partial | |
@await Html.PartialAsync("_Editor", stringModel/typedModel, new ViewDataDictionary(ViewData) | |
{ | |
["Scripts"] = scripts | |
}) | |
@section Scripts { | |
... | |
// Add _Editor.cshtml scripts | |
@foreach (var script in scripts) | |
{ | |
@script | |
} | |
... | |
} | |
EXPOSED API: | |
window.templateEditor { | |
getTemplate():template, | |
setTemplate(template) | |
} | |
*@ | |
@using Microsoft.AspNetCore.Html | |
@model dynamic | |
@functions | |
{ | |
public class TagDefinition | |
{ | |
public string Tag { get; set; } = null!; | |
public string Sample { get; set; } = null!; | |
public int MaxExpectedLength { get; set; } | |
public string ContentType { get; set; } = null!; // UTF, ASCII, GSM-7, etc. | |
public string Title { get; set; } = null!; | |
public string? Description { get; set; } | |
} | |
private static List<TagDefinition> Tags => new() | |
{ | |
new TagDefinition { Tag = "Title", Title = "عنوان", Sample = "آقا/خانم", Description = "افزودن عنوان آقا، خانم، یا آقا/خانم در صورت عدم تعیین جنسیت", MaxExpectedLength = 8, ContentType = "UTF" }, | |
new TagDefinition { Tag = "Respectful", Title = "محترم", Sample = "محترم", Description = "افزودن محترم", MaxExpectedLength = 5, ContentType = "UTF" }, | |
new TagDefinition { Tag = "FullName", Title = "نام کامل", Sample = "نام و نام خانوادگی", Description = "افزودن نام و نام خانوادگی مشتری", MaxExpectedLength = 25, ContentType = "UTF" }, | |
new TagDefinition { Tag = "FirstName", Title = "نام", Sample = "حسن", Description = "افزودن نام مشتری", MaxExpectedLength = 10, ContentType = "UTF" }, | |
new TagDefinition { Tag = "LastName", Title = "نام خانوادگی", Sample = "فقیهی", Description = "افزودن نام خانوادگی مشتری", MaxExpectedLength = 12, ContentType = "UTF" }, | |
new TagDefinition { Tag = "PhoneNumber", Title = "شماره تلفن", Sample = "09123456789", Description = "افزودن شماره تلفن ایرانی کار", MaxExpectedLength = 11, ContentType = "ASCII" }, | |
new TagDefinition { Tag = "CompanyName", Title = "نام شرکت", Sample = "ایرانیکار", Description = "افزودن عنوان ایرانی کار", MaxExpectedLength = 20, ContentType = "UTF" }, | |
new TagDefinition { Tag = "Link", Title = "لینک", Sample = "https://iranecar.com", Description = "افزودن لینک ایرانی کار", MaxExpectedLength = 21, ContentType = "ASCII" }, | |
new TagDefinition { Tag = "Date", Title = "تاریخ", Sample = "1404/04/07", Description = "افزودن تاریخ روز", MaxExpectedLength = 10, ContentType = "ASCII" }, | |
new TagDefinition { Tag = "Time", Title = "زمان", Sample = "14:30", Description = "افزودن ساعت روز", MaxExpectedLength = 5, ContentType = "ASCII" }, | |
}; | |
} | |
@{ | |
Layout = null; | |
} | |
<style> | |
.editor-container { | |
max-width: 800px; | |
margin: 0 auto; | |
background: white; | |
border-radius: 10px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
overflow: hidden; | |
} | |
.toolbar { | |
background: #2c3e50; | |
padding: 15px; | |
display: flex; | |
gap: 10px; | |
flex-wrap: wrap; | |
} | |
.tag-button { | |
background: #3498db; | |
color: white; | |
border: none; | |
padding: 8px 12px; | |
border-radius: 5px; | |
cursor: pointer; | |
font-size: 12px; | |
transition: background 0.3s; | |
} | |
.tag-button:hover { | |
background: #2980b9; | |
} | |
.editor { | |
position: relative; | |
min-height: 300px; | |
padding: 30px 55px 20px 20px; | |
font-size: 16px; | |
line-height: 30px; | |
outline: none; | |
background: white; | |
cursor: text; | |
word-wrap: break-word; | |
white-space: pre-wrap; | |
direction: rtl; | |
text-align: right; | |
/* Notebook styling */ | |
background-image: | |
linear-gradient(-90deg, transparent 49px, #ffe0c4 0, #ffe0c4 51px, transparent 0), | |
linear-gradient(#eee 1px, transparent 1px); | |
background-size: 100% 30px, 100% 30px; | |
background-position: 0 0; | |
} | |
.editor:empty::before { | |
content: 'متن پیام خود را اینجا تایپ کنید...'; | |
color: #999; | |
font-style: italic; | |
} | |
.visual-tag { | |
display: inline-block; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
padding: 2px 8px; | |
border-radius: 12px; | |
font-size: 11px; | |
font-weight: bold; | |
margin: 0 1px; | |
position: relative; | |
cursor: pointer; | |
user-select: none; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
vertical-align: baseline; | |
} | |
.visual-tag:hover { | |
transform: translateY(-1px); | |
box-shadow: 0 3px 6px rgba(0,0,0,0.3); | |
} | |
.visual-tag::after { | |
content: attr(data-description); | |
position: absolute; | |
bottom: 100%; | |
left: 50%; | |
transform: translateX(-50%); | |
background: #2c3e50; | |
color: white; | |
padding: 4px 8px; | |
border-radius: 4px; | |
font-size: 10px; | |
white-space: nowrap; | |
opacity: 0; | |
pointer-events: none; | |
transition: opacity 0.3s; | |
z-index: 1000; | |
} | |
.visual-tag:hover::after { | |
opacity: 1; | |
} | |
.output-section { | |
margin-top: 20px; | |
padding: 20px; | |
background: #ecf0f1; | |
} | |
.output-box { | |
background: white; | |
border: 1px solid #bdc3c7; | |
border-radius: 5px; | |
padding: 15px; | |
font-family: monospace; | |
font-size: 12px; | |
white-space: pre-wrap; | |
margin-bottom: 10px; | |
} | |
/*.char-count {*/ | |
/* text-align: left;*/ | |
/* color: #7f8c8d;*/ | |
/* font-size: 12px;*/ | |
/* margin-top: 10px;*/ | |
/*}*/ | |
/*.explanationBox {*/ | |
/* color: #7d9091;*/ | |
/* font-size: 12px;*/ | |
/* margin-top: 10px;*/ | |
/*}*/ | |
.preview-section { | |
background: #e8f5e8; | |
padding: 15px; | |
border-radius: 5px; | |
margin-top: 10px; | |
} | |
.preview-title { | |
font-weight: bold; | |
margin-bottom: 10px; | |
} | |
.preview-title.sample { | |
color: #27ae60; | |
} | |
.preview-title.template { | |
color: #476e77; | |
} | |
.stats-row { | |
display: flex; | |
gap: 15px; | |
margin-bottom: 20px; /* space before the template output */ | |
} | |
.stat-item { | |
flex: 1; | |
background: white; | |
border: 2px solid #e2e8f0; | |
border-radius: 8px; | |
padding: 10px; | |
text-align: center; | |
} | |
.stat-number { | |
font-size: 24px; | |
font-weight: bold; | |
color: #2d3748; | |
} | |
.stat-label { | |
font-size: 12px; | |
color: #718096; | |
margin-top: 5px; | |
} | |
/* explanation tooltip */ | |
.explanation-trigger { | |
position: relative; | |
} | |
.explanationBox { | |
display: none; | |
position: absolute; | |
bottom: calc(100% + 8px); | |
left: 50%; | |
transform: translateX(-50%); | |
width: 190px; | |
max-width: 200px; /* limit width */ | |
max-height: 150px; /* limit height */ | |
white-space: normal; /* allow wrapping */ | |
/*overflow-wrap: anywhere; !* break long words *!*/ | |
hyphens: auto; /* add hyphens */ | |
overflow-y: auto; /* scroll if too tall */ | |
background: rgba(45,55,72,0.95); | |
color: white; | |
padding: 6px 10px; | |
border-radius: 6px; | |
font-size: 11px; | |
z-index: 1000; | |
} | |
.explanation-trigger:hover .explanationBox { | |
display: block; | |
} | |
</style> | |
<div class="editor-container"> | |
<div class="toolbar"> | |
@foreach (var tag in Tags) | |
{ | |
@* !!! IMPORTANT NOTE: this piece of code get overwritten by JS code in LoadTagScript() at runtime, trying to keep toolbar in sync for runtime changes *@ | |
@* The runtime behaviour mentioned is not yet available or implemented *@ | |
<button type="button" class="tag-button" data-tag="{{@tag.Tag}}" data-title="@tag.Title" data-description="@tag.Description" title="@tag.Description"> | |
@tag.Title | |
</button> | |
} | |
</div> | |
<input type="hidden" | |
name="@(Model switch | |
{ | |
null => "SmsTemplate", | |
string => "SmsTemplate", | |
_ => Model.InputName | |
})" | |
id="smsTemplateInput" | |
value="@(Model switch | |
{ | |
null => "", | |
string => Model, | |
_ => Model.CurrentValue | |
})" /> | |
<div class="editor" contenteditable="true" id="smsEditor"> | |
@Html.Raw(Model switch | |
{ | |
null => "", | |
string => Model, | |
_ => Model.CurrentValue | |
}) | |
</div> | |
</div> | |
<div class="output-section"> | |
@* <div class="char-count" id="sampleCharCount">تعداد کاراکتر نمونه: 0</div> *@ | |
@* <div class="explanationBox" id="explanationBox"></div> *@ | |
<div class="stats-row"> | |
<div class="stat-item explanation-trigger"> | |
<div class="stat-number" id="maxExpectedCharCount">0</div> | |
<div class="stat-label">تعداد کاراکتر احتمالی</div> | |
<div class="explanationBox" id="maxExpectedCharCountExplanationBox"> | |
این عدد از جمع «حداکثر طول در نظر گرفته شده» همه تگها، بهعلاوه کاراکتر های متن عادی محاسبه میشود. این مقدار صرفاً پیشنهادی است و انتظار ما از یک پیام معمولی می باشد، ولی همیشه استثنا وجود دارد و قانونی برای حداکثر کاراکتر تعیین نمی کند. | |
</div> | |
</div> | |
<div class="stat-item explanation-trigger"> | |
<div class="stat-number" id="expectedSmsCount">0</div> | |
<div class="stat-label">تعداد پیام احتمالی</div> | |
<div class="explanationBox" id="expectedSmsCountExplanationBox"></div> | |
</div> | |
<div class="stat-item explanation-trigger"> | |
<div class="stat-number" id="sampleCharCount">0</div> | |
<div class="stat-label">تعداد کاراکتر نمونه</div> | |
<div class="explanationBox" id="sampleCharCountExplanationBox"> | |
تعداد کاراکتر محاسبه شده بر اساس استفاده از الگو با داده های نمونه | |
</div> | |
</div> | |
<div class="stat-item explanation-trigger"> | |
<div class="stat-number" id="sampleSmsCount">0</div> | |
<div class="stat-label">تعداد پیام نمونه</div> | |
<div class="explanationBox" id="sampleSmsCountExplanationBox"></div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="tagCount">0</div> | |
<div class="stat-label">تعداد تگ</div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="sampleWordCount">0</div> | |
<div class="stat-label">تعداد کلمه</div> | |
</div> | |
<div class="stat-item" style="display:none"> | |
<div class="stat-number" id="templateLength">0</div> | |
<div class="stat-label">طول تمپلیت</div> | |
</div> | |
</div> | |
<div class="preview-section"> | |
<div class="preview-title sample">پیشنمایش با دادههای نمونه:</div> | |
<div class="output-box" id="previewOutput"></div> | |
</div> | |
<div class="preview-section"> | |
<div class="preview-title template">خروجی قابل ذخیره (Template):</div> | |
<div class="output-box" id="templateOutput"></div> | |
</div> | |
</div> | |
@functions | |
{ | |
private IHtmlContent TagJsonScript() => new HtmlString($@" | |
<script id='tag-data' type='application/json'> | |
{Json.Serialize(Tags)} | |
</script> | |
"); | |
private IHtmlContent LoadTagScript() => new HtmlString($@" | |
<script> | |
$(function () {{ | |
const tagScript = document.getElementById('tag-data'); | |
const tagData = JSON.parse(tagScript.textContent); | |
window.tags = tagData; | |
const toolbar = document.querySelector('.toolbar'); | |
if (toolbar) {{ | |
toolbar.innerHTML = ''; | |
tagData.forEach(tag => {{ | |
const button = document.createElement('button'); | |
button.type = 'button'; | |
button.className = 'tag-button'; | |
button.setAttribute('data-tag', `{{{{${{tag.tag}}}}}}`); | |
button.setAttribute('data-title', tag.title); | |
button.setAttribute('data-desc', tag.description); | |
button.setAttribute('title', tag.description); | |
button.innerText = tag.title ?? tag.title; | |
toolbar.appendChild(button); | |
}}); | |
}} | |
}}); | |
</script> | |
"); | |
private IHtmlContent LoadEditorCoreScript() => new HtmlString($@" | |
<script> | |
/* | |
* Editor Core Functionality | |
* */ | |
$(document).ready(function() {{ | |
const editor = $('#smsEditor'); | |
const templateOutput = $('#templateOutput'); | |
const previewOutput = $('#previewOutput'); | |
const pasteInput = $('#pasteInput'); | |
const tags = window.tags; | |
// || [ | |
// {{ tag: 'Title', title: 'عنوان' , sample: 'آقا/خانم', maxExpectedLength: 8, contentType: 'UTF' }}, | |
// {{ tag: 'Respectful', title: 'محترم', sample: 'محترم', maxExpectedLength: 5, contentType: 'UTF' }}, | |
// {{ tag: 'FullName', title: 'نام کامل', sample: 'حسن فقیهی', maxExpectedLength: 25, contentType: 'UTF' }}, | |
// {{ tag: 'FirstName', title: 'نام', sample: 'حسن', maxExpectedLength: 10, contentType: 'UTF' }}, | |
// {{ tag: 'LastName', title: 'نام خانوادگی', sample: 'فقیهی', maxExpectedLength: 12, contentType: 'UTF' }}, | |
// {{ tag: 'PhoneNumber', title: 'شماره تلفن', sample: '09130000000', maxExpectedLength: 11, contentType: 'ASCII' }}, | |
// {{ tag: 'CompanyName', title: 'نام شرکت', sample: 'ایرانیکار', maxExpectedLength: 12, contentType: 'UTF' }}, | |
// {{ tag: 'Date', title: 'تاریخ', sample: '1404/04/07', maxExpectedLength: 10, contentType: 'ASCII' }}, | |
// {{ tag: 'Time', title: 'زمان', sample: '14:30', maxExpectedLength: 5, contentType: 'ASCII' }}, | |
// {{ tag: 'Link', title: 'لینک', sample: 'https://example.com', maxExpectedLength: 35, contentType: 'ASCII' }}, | |
// {{ tag: 'PlateNumber', title: 'شماره پلاک', sample: '12ج345-67', maxExpectedLength: 9, contentType: 'UTF' }} | |
// ]; | |
const sampleData = Object.fromEntries(tags.map(t => [`{{{{${{t.tag}}}}}}`, t.sample])); | |
const validTags = Object.fromEntries(tags.map(t => [`{{{{${{t.tag}}}}}}`, t])); | |
let isProcessing = false; | |
$('.tag-button').click(function() {{ | |
const tag = $(this).data('tag'); | |
const title = $(this).data('title'); | |
const description = $(this).data('desc'); | |
insertTag(tag, title, description); | |
}}); | |
function insertTag(tag, title, description) {{ | |
editor.focus(); | |
const selection = window.getSelection(); | |
let range; | |
if (selection.rangeCount > 0) {{ | |
range = selection.getRangeAt(0); | |
}} else {{ | |
range = document.createRange(); | |
range.selectNodeContents(editor[0]); | |
range.collapse(false); | |
}} | |
const tagElement = document.createElement('span'); | |
tagElement.className = 'visual-tag'; | |
tagElement.setAttribute('data-tag', tag); | |
tagElement.setAttribute('data-description', description); | |
tagElement.setAttribute('contenteditable', 'false'); | |
tagElement.textContent = title; | |
range.deleteContents(); | |
range.insertNode(tagElement); | |
range.setStartAfter(tagElement); | |
range.collapse(true); | |
selection.removeAllRanges(); | |
selection.addRange(range); | |
updateOutputs(); | |
}} | |
editor.on('input', function(e) {{ | |
if (isProcessing) return; | |
clearTimeout(window.inputTimeout); | |
window.inputTimeout = setTimeout(function() {{ | |
processContent(); | |
}}, 100); | |
}}); | |
// ENFORCE UNFORMATTED TEXT, PASTE WITH FORMATTING, CAN CAUSE TAG TO BEHAVE INCORRECTLY | |
editor.on('paste', function(e) {{ | |
e.preventDefault(); | |
const text = (e.originalEvent || e).clipboardData.getData('text/plain'); | |
// Insert as unformatted text at the cursor | |
document.execCommand('insertText', false, text); | |
}}); | |
function processContent() {{ | |
if (isProcessing) return; | |
isProcessing = true; | |
try {{ | |
const walker = document.createTreeWalker(editor[0], NodeFilter.SHOW_TEXT, null, false); | |
const textNodes = []; | |
let node; | |
while (node = walker.nextNode()) {{ | |
textNodes.push(node); | |
}} | |
textNodes.forEach(textNode => {{ | |
let content = textNode.textContent; | |
let newContent = content; | |
Object.keys(validTags).forEach(tag => {{ | |
const regex = new RegExp(tag.replace(/[{{}}]/g, '\\$&'), 'g'); | |
if (regex.test(newContent)) {{ | |
const placeholder = `__TAG_${{tag.replace(/[{{}}]/g, '')}}_PLACEHOLDER__`; | |
newContent = newContent.replace(regex, placeholder); | |
}} | |
}}); | |
if (newContent !== content) {{ | |
let processedHTML = newContent; | |
debugger | |
Object.keys(validTags).forEach(tag => {{ | |
const placeholder = `__TAG_${{tag.replace(/[{{}}]/g, '')}}_PLACEHOLDER__`; | |
const tagHTML = `<span class=""visual-tag"" data-tag=""${{tag}}"" data-description=""${{validTags[tag].description}}"" contenteditable=""false"">${{validTags[tag].title}}</span>`; | |
processedHTML = processedHTML.replace(new RegExp(placeholder, 'g'), tagHTML); | |
}}); | |
const fragment = document.createRange().createContextualFragment(processedHTML); | |
textNode.parentNode.replaceChild(fragment, textNode); | |
}} | |
}}); | |
}} catch (error) {{ | |
console.error('Error processing content:', error); | |
}} finally {{ | |
isProcessing = false; | |
updateOutputs(); | |
}} | |
}} | |
editor.on('keydown', function(e) {{ | |
const selection = window.getSelection(); | |
if ((e.key === 'Backspace' || e.key === 'Delete') && selection.rangeCount > 0) {{ | |
const range = selection.getRangeAt(0); | |
if (range.collapsed) {{ | |
let targetElement = null; | |
if (e.key === 'Backspace') {{ | |
let currentNode = range.startContainer; | |
if (currentNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) {{ | |
let prevSibling = currentNode.previousSibling; | |
if (prevSibling && $(prevSibling).hasClass('visual-tag')) targetElement = prevSibling; | |
}} else if (currentNode.nodeType === Node.ELEMENT_NODE) {{ | |
let child = currentNode.childNodes[range.startOffset - 1]; | |
if (child && $(child).hasClass('visual-tag')) targetElement = child; | |
}} | |
}} else if (e.key === 'Delete') {{ | |
let currentNode = range.startContainer; | |
if (currentNode.nodeType === Node.TEXT_NODE && range.startOffset === currentNode.textContent.length) {{ | |
let nextSibling = currentNode.nextSibling; | |
if (nextSibling && $(nextSibling).hasClass('visual-tag')) targetElement = nextSibling; | |
}} else if (currentNode.nodeType === Node.ELEMENT_NODE) {{ | |
let child = currentNode.childNodes[range.startOffset]; | |
if (child && $(child).hasClass('visual-tag')) targetElement = child; | |
}} | |
}} | |
if (targetElement) {{ | |
e.preventDefault(); | |
targetElement.remove(); | |
updateOutputs(); | |
return false; | |
}} | |
}} | |
}} | |
setTimeout(updateOutputs, 50); | |
}}); | |
function updateOutputs() {{ | |
if (isProcessing) return; | |
let template = ''; | |
let preview = ''; | |
let tagCount = 0; | |
editor[0].childNodes.forEach(function(node) {{ | |
if (node.nodeType === Node.TEXT_NODE) {{ | |
template += node.textContent; | |
preview += node.textContent; | |
}} else if (node.nodeType === Node.ELEMENT_NODE && $(node).hasClass('visual-tag')) {{ | |
const tag = $(node).attr('data-tag'); | |
template += tag; | |
preview += sampleData[tag] || tag; | |
tagCount++; | |
}} else if (node.nodeType === Node.ELEMENT_NODE) {{ | |
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ALL, null, false); | |
let childNode; | |
while (childNode = walker.nextNode()) {{ | |
if (childNode.nodeType === Node.TEXT_NODE) {{ | |
template += childNode.textContent; | |
preview += childNode.textContent; | |
}} else if (childNode.nodeType === Node.ELEMENT_NODE && $(childNode).hasClass('visual-tag')) {{ | |
const tag = $(childNode).attr('data-tag'); | |
template += tag; | |
preview += sampleData[tag] || tag; | |
tagCount++; | |
}} | |
}} | |
}} | |
}}); | |
templateOutput.text(template); | |
previewOutput.text(preview); | |
// Based on SAMPLE data | |
const sampleSmsStats = calculateSMS(preview); | |
// Based On MAX expected length | |
const expectedMessage = generateExpectedMessage(template) | |
const expectedStats = calculateSMS(expectedMessage); | |
$('.stat-item #maxExpectedCharCount').text(expectedStats.charCount); | |
$('.stat-item #expectedSmsCount').text(expectedStats.segments); | |
$('.stat-item #sampleCharCount').text(sampleSmsStats.charCount); | |
$('.stat-item #sampleSmsCount').text(sampleSmsStats.segments); | |
$('.stat-item #tagCount').text(tagCount); | |
$('.stat-item #sampleWordCount').text(preview.trim().split(/\s+/).filter(w => w.length > 0).length); | |
$('.stat-item #templateLength').text(template.length); | |
$('.explanation-trigger .explanationBox#expectedSmsCountExplanationBox').text(expectedStats.explanation); | |
$('.explanation-trigger .explanationBox#sampleSmsCountExplanationBox').text(sampleSmsStats.explanation); | |
changeColor('#maxExpectedCharCount', expectedStats); | |
changeColor('#sampleCharCount', sampleSmsStats); | |
function changeColor(selector, calcData){{ | |
const charElement = $(selector); | |
if (calcData.charCount <= calcData.perSegmentLimit) {{ | |
charElement.css('color', '#38a169'); | |
}} else if (calcData.charCount <= calcData.perSegmentLimit * 2) {{ | |
charElement.css('color', '#d69e2e'); | |
}} else {{ | |
charElement.css('color', '#e53e3e'); | |
}} | |
}} | |
}} | |
window.pasteTemplate = function() {{ | |
const templateText = pasteInput.val().trim(); | |
if (!templateText) return; | |
editor.empty(); | |
let processedHTML = templateText; | |
Object.keys(validTags).forEach(tag => {{ | |
const regex = new RegExp(tag.replace(/[{{}}]/g, '\\$&'), 'g'); | |
const tagHTML = `<span class=""visual-tag"" data-tag=""${{tag}}"" data-description=""${{validTags[tag].description}}"" contenteditable=""false"">${{validTags[tag].title}}</span>`; | |
processedHTML = processedHTML.replace(regex, tagHTML); | |
}}); | |
editor.html(processedHTML); | |
pasteInput.val(''); | |
updateOutputs(); | |
editor.focus(); | |
showNotification('تمپلیت با موفقیت اعمال شد!'); | |
}}; | |
pasteInput.on('keydown', function(e) {{ | |
if (e.key === 'Enter') {{ | |
e.preventDefault(); | |
pasteTemplate(); | |
}} | |
}}); | |
window.copyToClipboard = function(elementId) {{ | |
const element = document.getElementById(elementId); | |
const text = element.textContent; | |
if (navigator.clipboard && window.isSecureContext) {{ | |
navigator.clipboard.writeText(text).then(function() {{ | |
showNotification('متن کپی شد!'); | |
}}); | |
}} else {{ | |
const textArea = document.createElement('textarea'); | |
textArea.value = text; | |
document.body.appendChild(textArea); | |
textArea.select(); | |
document.execCommand('copy'); | |
document.body.removeChild(textArea); | |
showNotification('متن کپی شد!'); | |
}} | |
}}; | |
function showNotification(message) {{ | |
const notification = $(`<div style=""position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #48bb78; color: white; padding: 10px 20px; border-radius: 5px; z-index: 10000;"">${{message}}</div>`); | |
$('body').append(notification); | |
setTimeout(function() {{ | |
notification.fadeOut(function() {{ | |
notification.remove(); | |
}}); | |
}}, 2000); | |
}} | |
editor.on('click', function(e) {{ | |
if (!$(e.target).hasClass('visual-tag')) {{ | |
editor.focus(); | |
}} | |
}}); | |
editor.on('selectstart', function(e) {{ | |
if ($(e.target).hasClass('visual-tag')) {{ | |
e.preventDefault(); | |
return false; | |
}} | |
}}); | |
// ✂️ Strip out empty text‑nodes (just spaces/newlines) so they don’t count (node added due to source tag placement) | |
editor.contents().filter(function() {{ | |
return this.nodeType === Node.TEXT_NODE && !/\S/.test(this.nodeValue); | |
}}).remove(); | |
editor.focus(); | |
updateOutputs(); | |
function detectEncoding(text) {{ | |
const gsmChars = /^[\x00-\x7F]*$/; | |
return gsmChars.test(text) ? 'ASCII' : 'UTF'; | |
}} | |
function calculateSMS(message) {{ | |
const encoding = detectEncoding(message); | |
const charCount = message.length; | |
const singleLimit = encoding === 'UTF' ? 70 : 160; | |
const multiLimit = encoding === 'UTF' ? 67 : 153; | |
const segments = charCount <= singleLimit ? 1 : Math.ceil(charCount / multiLimit); | |
const explanation = segments > 1 | |
? `📄 پیام شما شامل ${{charCount}} کاراکتر ${{encoding === 'UTF' ? 'یونیکد' : 'لاتین'}} است، به همین دلیل طول آن ${{segments}} پیام است. (هر پیام ${{encoding === 'UTF' ? 'یونیکد' : 'لاتین'}} تا ${{singleLimit}} کاراکتر، و پیامهای ادامهدار فقط ${{multiLimit}} کاراکتر را در خود جای میدهند.)` | |
: `📄 پیام شما شامل ${{charCount}} کاراکتر ${{encoding === 'UTF' ? 'یونیکد' : 'لاتین'}} است.`; | |
return {{ charCount, encoding, segments, perSegmentLimit: singleLimit, continuationLimit: multiLimit, explanation }}; | |
}} | |
function generateExpectedMessage(template) {{ | |
let result = ''; | |
let cursor = 0; | |
const regex = /{{{{[^{{}}]+}}}}/g; | |
let match; | |
while ((match = regex.exec(template)) !== null) {{ | |
// append text before tag | |
result += template.slice(cursor, match.index); | |
const tagToken = match[0]; | |
const def = tags.find(t => `{{{{${{t.tag}}}}}}` === tagToken); | |
if (def) {{ | |
// use ASCII filler for ASCII tags, UTF filler for UTF tags | |
const fillerChar = def.contentType === 'UTF' ? 'ا' : 'A'; | |
result += fillerChar.repeat(def.maxExpectedLength); | |
}} else {{ | |
// unknown tags treated as literal text | |
result += tagToken; | |
}} | |
cursor = match.index + tagToken.length; | |
}} | |
// append remaining text | |
result += template.slice(cursor); | |
return result; | |
}} | |
// Expose API for external control | |
window.templateEditor = {{ | |
/** | |
* Get current template string (with tags) | |
*/ | |
getTemplate: function() {{ | |
return templateOutput.text(); | |
}}, | |
/** | |
* Set template string (with tags) into editor | |
* @param {{string}} tpl - template text with tags | |
*/ | |
setTemplate: function(tpl) {{ | |
// Clear editor and render tags | |
editor.empty(); | |
// Use same logic as pasteTemplate | |
let processedHTML = tpl; | |
Object.keys(sampleData).forEach(tag => {{ | |
const regex = new RegExp(tag.replace(/[{{}}]/g, '\$&'), 'g'); | |
const tagHTML = `<span class=""visual-tag"" data-tag=""${{tag}}"" data-description=""${{validTags[tag].description}}"" contenteditable=""false"">${{validTags[tag].title}}</span>`; | |
processedHTML = processedHTML.replace(regex, tagHTML); | |
}}); | |
editor.html(processedHTML); | |
updateOutputs(); | |
editor.focus(); | |
}} | |
}}; | |
}}); | |
</script> | |
"); | |
private IHtmlContent UpdateInputScript() => new HtmlString($@" | |
<script> | |
/* | |
* Keep the Input updated | |
* */ | |
const $hiddenInput = $('#smsTemplateInput'); | |
const $templateOutput = $('#templateOutput'); | |
const observer = new MutationObserver(function () {{ | |
if ($templateOutput.length && $hiddenInput.length) {{ | |
$hiddenInput.val($templateOutput.text() || """"); | |
}} | |
}}); | |
if ($templateOutput.length) {{ | |
observer.observe($templateOutput[0], {{ childList: true, subtree: true, characterData: true }}); | |
}} | |
</script> | |
"); | |
} | |
@{ | |
// Pass it to the caller via ViewData or another mechanism | |
ViewData["Scripts"] ??= new List<IHtmlContent>(); | |
var scriptList = (List<IHtmlContent>)ViewData["Scripts"]!; | |
scriptList.Add(TagJsonScript()); | |
scriptList.Add(LoadTagScript()); | |
scriptList.Add(LoadEditorCoreScript()); | |
scriptList.Add(UpdateInputScript()); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment