스마트편집기
키슈 에디터 스마트편집기 개발과 기타 IT 관련 관심 사항을 기록해보려고 합니다.
#키슈 스마트편집기 5.0
html 편집이 가능한 레이어 마우스 오버 효과
키슈 플랫폼에 컨텐츠를 채울겸 추가로 개발을 진행하는 스마트편집기5.0 프로젝트 스토리를 기록해 보려고 한다.
html 편집화면에서 이미지, 텍스트, 아이콘에 마우스 오버시 테두리효과를 처음에 적용할때는 border, outline 등의 스타일로 오버효과를 주었다. editorx 를 살펴보다보니 독특한 구성으로 개발이 되었다.
편집하려는 레이어에 효과를 준 것이 아니고 편집 할 레이어의 위치 값, width, height 등의 값을 가져와서 별도의 레이어로 효과를 주고 있었다.
편집하는 레이어에 편집을 위한 스타일 변경이나 구조변경이 없어 좋은 방법으로 보인다.
그래서 editorx 과 유사하게 간단한 샘플을 만들어 보았다. 편집화면과 효과 코드가 분리가 되어 만족스러웠다.
여기에 마우스 효과에 필요한 기본 베이스 코드를 기록해보자.
마우스 오버할 때 영역이 표시되고 클릭을 하면 영역이 선택되어 편집이 가능하고 패널을 동적으로 생성하여 영역의 정보를 출력하고 수정할 수 있도록 하였다.
이렇게 텍스트, 영역, 이미지, 아이콘 등 원하는 영역을 편집하고 템플릿, 모듈, 스킨 형식으로 분리된 html 조각을 저장하고 불러오고 조합하여 url을 제공해 주는 구조로 진행하면 될 듯 하다.
우선은 아래 코드와 같이 핵심 코어라고 할 수 있는 코드를 우선 개발해 보았다. 이러한 패턴으로 웹페이지의 전반적인 컨텐츠를 편집하고 배포할 수 있을 듯 하다.
지금 글을 등록하고 있는 사이트 "키워드 이슈 공유라는 SNS 플랫폼 프로젝트"를 혼자 개발 하고 있는데 이미지 업로드 영역과 이벤트 카테고리 영역에 "프로모션 자동 등록" 이라는 기능으로 추가해 볼 생각이다.
앞으로 코어 코드는 개발 방향에 맞게 리팩토링 하면서 프로젝트를 진행해 보고자 한다.
현재 비공개중인 스마트편집기 3.5코드는 저작권 등록까지 완료 된 상태이지만 좀 부족한 부분이 있어 라이브로 진행하지 못하고 비공개 중이다. 비공개중에도 4.5까지 많은 코드가 업데이트 되었다.
스마트편집기 5.0 버전 핵심중에 핵심은 이미지 AI 도입이다. 업로드 하는 이미지의 특정 부분만 남기는 배경 삭제가 가능하여 투명한 png 이미지로 업로드가 가능하다는 것이다. 10여년 전에 이러한 기술이 가능한지 웹서핑에 올인하던 때가 있었다. AI 도입으로 가능하게 될 줄이야!
스마트편집기 5.0 버전은 Next.js 프레임워크로 개발을 하고 이번에는 꼭 라이브 되어서 무료 서비스까지 진행 되었으면 좋겠다.
css 코드
*{box-sizing: border-box;}
html, body{height: 100%;margin: 0;padding: 0;overflow: hidden;}
iframe{border: 0;}
.smtedit-wrap{position: relative;}
.smt-selected-layer{position: absolute;left: 0;top: 0;right: 0;bottom: 0;pointer-events: none;overflow: visible;line-height: 12px;z-index: 1;}
.smt-editing{position:absolute;top:0;left:0;border: 1px dotted #0084ff;opacity:1;transition: opacity 0.3s;z-index: 11;}
.scroll-on .smt-editing{opacity: 0;}
.smt-editing-selected{position:absolute;top:0;left:0;border: 1px solid #0084ff;z-index: 12;}
.smt-editing-selected div{pointer-events: none;}
.smt-editing-tooltip{position: absolute;top:-22px;left:-1px;height: 22px;padding:2px 4px;color:#fff;font-size: 11px;background-color:#0084ff;overflow: hidden;}
.smt-editing .smt-editing-tooltip{border: 1px dotted #404040;background-color: #404040;}
.smt-editing-selected .smt-editing-tooltip{border: 1px solid #0084ff;}
.smt-editing-setting-box{position: fixed;top:-70px;left:0;width:calc(100% - 17px);height:70px;pointer-events: all;color:#d9d9d9;border-bottom: 1px solid #212121;background-color: #404040;opacity:0;transition:all 1s;z-index: 99999;}
.open-edit-box .smt-editing-setting-box{top:0;padding:10px 20px;opacity: 1;}
.smt-editing-setting-box .close{position: absolute;top: 5px;right: 5px;cursor: pointer;}
[contenteditable='true'], [contenteditable='true'] * {
cursor: text !important;
}
.edit-property-wrap ul{font-size: 0;}
.edit-property-wrap li{display: inline-block;margin-right: 15px;font-size: 14px;}
.edit-property-title{margin-bottom: 15px;}
html 코드
//base html
<!DOCTYPE html>
<html lang="kr">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Keyssue Editor TEST</title>
</head>
<body>
<div id="smteditWrap" class="smtedit-wrap" style="height:100%;">
<div class="smt-inner-layout" style="height:100%;">
<div id="smtSelectedLayer" class="smt-selected-layer"><div></div></div>
<div id="smtEditCanvas" style="height:100%;">
<iframe id="smtEditIframe" src="/smt_iframe.html" style="width: 100%;height: 100%;"></iframe>
</div>
</div>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script type="text/javascript" src="/static/js/smtedit.js"></script>
</body>
</html>
// iframe html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyssue Editor TEST</title>
</head>
<body>
<main style="padding: 100px 0;">
<div data-smt-type="box" style="height: 500px;">
<div data-smt-type="box">
<h3 data-smt-type="txt">
키슈 에디터 편집을 해주세요. 텍스트 영역입니다. 텍스트, 이미지, 배경색, 아이콘 편집 가능합니다.
</h3>
<ul>
<li>
<span data-smt-type="txt">텍스트 영역 111 편집해주세요.</span>
</li>
<li>
<div data-smt-type="txt">텍스트 영역 333 편집해주세요.</div>
</li>
<li>
<div data-smt-type="txt">텍스트 영역 333 편집해주세요.</div>
</li>
</ul>
</div>
</div>
</main>
</body>
</html>
js 코드
/*
* KEYSSUE EDIT
*/
'use strict';
function Smtedit() {
this.window = $(window);
this.body = $('body');
this.smtWrap = $('#smteditWrap');
this.smtSelectedLayer = $('#smtSelectedLayer');
this.smtEditCanvas = $('#smtEditCanvas');
this.editClassSelected = '.smt-editing-selected';
this.editClassSelectedName = 'smt-editing-selected';
this.editClass = '.smt-editing';
this.editClassName = 'smt-editing';
this.editClassScrollData = {};
this.editEle = '';
this.iframeContents = $('#smtEditIframe').contents();
this.iframeContentsWrapper = this.iframeContents.find('main');
this.iframeSelecter = '';
this.iframeSelecterCurrent = null;
this.selecteElementdCurrent = null;
// this.editingElement = this.iframeContents.find(this.editClass);
this.editClassSelectedPrev = null;
// default data
this.boxData = {};
this.imgData = {};
this.txtData = {};
this.iconData = {};
};
const $se = Smtedit.prototype;
$se.init = function() {
this.bindEvents();
}
$se.bindEvents = function() {
this.iframeContentsWrapper.on('click focus mouseover', this.selectedElementEvent);
this.smtSelectedLayer.on('click', '.close', this.closeEditElementEvent);
this.iframeContents.on('scroll', this.iframeScrollEvent(this.elementSelectedScroll));
this.window.on('resize', this.iframeScrollEvent(smt.elementSelectedScroll));
// contenteditable
// document.getElementById("editor").addEventListener("input", function() {}, false);
// $('body').on('input', '[contenteditable]', function() {}, fase);
// event
// change 요소 변경이 끝나면 발생
// input 값을 수정할 때마다 발생
// cut, copy, paste 값을 잘라내기·복사하기·붙여넣기 할 때 발생
}
$se.utilEvent = {
checkTouchDevice: function() {
if (navigator.userAgent.indexOf('Windows') > -1 || navigator.userAgent.indexOf('Macintosh') > -1) {
return this.checkTouchDevice = false;
} else if ('ontouchstart' in window || (window.DocumentTouch && document instanceof window.DocumentTouch)) {
return this.checkTouchDevice = true;
}
},
getElementSize: function(ele){
return { width: ele.outerWidth(), height: ele.outerHeight() };
},
getOffset: function(ele){
let top = Math.round(ele.offset().top),
bottom = Math.round(ele.offset().bottom),
left = Math.round(ele.offset().left);
return { top, left, bottom };
},
getUserAgent: function(){
return navigator.userAgent;
}
};
$se.selectedElementEvent = function(e) {
let target = $(e.target);
let eventType = e.type;
let editEleType = target.data('smt-type');
let isTargetSelected = target.hasClass(smt.editClassSelectedName);
if(editEleType === undefined || isTargetSelected ) return false;
let editTagName = target.prop('tagName');
let scrT = smt.iframeContents.scrollTop();
let size = smt.utilEvent.getElementSize(target);
let offs = smt.utilEvent.getOffset(target);
let classType = eventType === 'click' ? smt.editClassSelectedName : smt.editClassName;
let editEle = $('<div class="'+classType+'" style="top:'+(offs.top-scrT)+'px;left:'+offs.left+'px;width:'+size.width+'px;height:'+size.height+'px;"></div>');
let editToolTip = $('<div class="smt-editing-tooltip '+editEleType+'"><span>'+editEleType+'</span></div>');
// smt.editEle = editEle;
if(eventType === 'click') {
smt.selecteElementdCurrent = target;
smt.setEditTarget(target, editEleType); //iframe
smt.setEditElement(); //parent
target.off('input').on('input', smt.resizeEditElementEvent);
}
editEle.append(editToolTip);
smt.smtSelectedLayer.append(editEle);
smt.iframeSelecterCurrent = target;
eventType === 'mouseover' && target.off('mouseout').on('mouseout', function() { $(smt.editClass).remove() });
// console.log(eventType, classType, editTagName, target);
}
$se.setEditTarget = function(target, editEleType) {
smt.iframeSelecter = smt.iframeContentsWrapper.find(smt.editClassSelected); //prev
let type = smt.iframeSelecter.data('smt-type');
if(smt.iframeSelecter.length > 0 && type === 'txt') {
let html = smt.iframeSelecter.html().replace(/<div><br><\/div>/gi, '<br>');
let changeData = html.replace(/<div>/gi,'<br>').replace(/<\/div>/gi,'');
smt.iframeSelecter.html(changeData);
}
smt.iframeSelecter.removeClass(smt.editClassSelectedName).removeAttr('contenteditable');
target.addClass(smt.editClassSelectedName);
if(editEleType === 'txt') target.prop('contenteditable', true).focus();
}
$se.setEditElement = function() {
let editSettingBox = $('<div class="smt-editing-setting-box"><button type="button" class="close">닫기</button></div>');
this.removeEditElement();
this.smtSelectedLayer.append(editSettingBox).addClass('open-edit-box');
this.smtSelectedLayer.find(this.editClassSelected).remove();
this.setEditElementData();
}
$se.iframeScrollEvent = function(callback) {
let aniTimeout = null;
return function () {
if (aniTimeout) {
smt.body.addClass('scroll-on');
window.cancelAnimationFrame(aniTimeout);
}
aniTimeout = window.requestAnimationFrame(function () {
callback();
smt.body.removeClass('scroll-on');
})
}
}
$se.elementSelectedScroll = function() {
if(!smt.iframeSelecterCurrent) return;
let target = smt.iframeSelecterCurrent;
let width = target.outerWidth();
let height = target.outerHeight();
let left = Math.round(target.offset().left);
let top = Math.round(target.offset().top);
let scrT = smt.iframeContents.scrollTop();
smt.smtWrap.find(smt.editClassSelected +','+ smt.editClass).css({ top: (top-scrT), left: left, width: width, height: height });
}
$se.removeEditTarget = function() {
this.iframeSelecterCurrent.removeClass(this.editClassSelectedName).removeAttr('contenteditable');
}
$se.removeEditElement = function() {
this.smtSelectedLayer.removeClass('open-edit-box').children().remove();
}
$se.resizeEditElementEvent = function() {
let height = $(this).outerHeight();
smt.smtWrap.find(smt.editClassSelected).css({ height });
}
$se.closeEditElementEvent = function() {
smt.removeEditTarget();
smt.removeEditElement();
}
$se.setEditElementData = function() {
let type = this.iframeSelecterCurrent.data('smt-type');
let targetEle = this.smtSelectedLayer.find('.smt-editing-setting-box');
switch (type) {
case 'box':
this.boxData = this.getElementDataBox();
targetEle.append('<div>box UI</div>');
console.log('box UI');
break;
case 'img':
this.imgData = this.getElementDataImg();
this.imgData.editHtml = this.createElementDataHtml(type);
targetEle.append(this.imgData.editHtml);
console.log('img UI : ', this.imgData);
break;
case 'txt':
this.txtData = this.getElementDataTxt();
this.txtData.editHtml = this.createElementDataHtml(type);
targetEle.append(this.txtData.editHtml);
console.log('txt UI : ', this.txtData);
break;
case 'icon':
this.iconData = this.getElementDataIcon();
targetEle.append('<div>icon UI</div>');
console.log('icon UI');
break;
}
}
$se.getElementDataBox = function() {
let ele = this.iframeSelecterCurrent;
return {
}
}
$se.getElementDataImg = function() {
let ele = this.iframeSelecterCurrent;
return {
width: ele.width(),
height: ele.height(),
url: ele.attr('src'),
alt: ele.attr('alt'),
bg: this.colorAlphaSetHex(ele.css('background-color')),
blur: '',
brightness: '',
contrast: '',
grayscale: '',
rotateX: '',
rotateY: '',
scaleP: '',
scaleM: ''
}
}
$se.getElementDataTxt = function() {
let ele = this.iframeSelecterCurrent;
return {
color: this.colorAlphaSetHex(ele.css('color')),
colorRGB: ele.css('color'),
fontSize: ele.css('font-size'),
fontFamily: ele.css('font-family'),
fontWeight: ele.css('font-weight'),
bg: ele.css('background-color')
}
}
$se.getElementDataIcon = function() {
let ele = this.iframeSelecterCurrent;
return {
}
}
$se.createElementDataHtml = function(type) {
let data = '';
if(type === 'img') {
data += `<li><span>width :</span> ${this.imgData.width}</li>`;
data += `<li><span>Height :</span> ${this.imgData.height}</li>`;
data += `<li><span>URL :</span> ${this.imgData.url}</li>`;
data += `<li><span>Alt :</span> ${this.imgData.alt}</li>`;
data += `<li><span>Background :</span> ${this.imgData.bg}</li>`;
data += `<li><span>Blur :</span> ${this.imgData.blur}</li>`;
data += `<li><span>Brightness :</span> ${this.imgData.brightness}</li>`;
data += `<li><span>Contrast :</span> ${this.imgData.contrast}</li>`;
data += `<li><span>Grayscale :</span> ${this.imgData.grayscale}</li>`;
// data += `<li><span> :</span> ${this.imgData.rotateX}</li>`;
// data += `<li><span> :</span> ${this.imgData.rotateY}</li>`;
}
if(type === 'txt') {
data += `<li><span>color :</span> ${this.txtData.color}</li>`;
data += `<li><span>font-size :</span> ${this.txtData.fontSize}</li>`;
data += `<li><span>font-family :</span> ${this.txtData.fontFamily}</li>`;
data += `<li><span>font-weight :</span> ${this.txtData.fontWeight}</li>`;
}
return `
<div class="edit-property-wrap">
<div class="edit-property-${type}">
<div class="edit-property-title">${type} STYLE</div>
<ul>${data}</ul>
</div>
</div>
`;
}
$se.colorAlphaSet = function(hex, alpha){
hex = hex.replace('#','');
let r = parseInt(hex.substring(0,2), 16);
let g = parseInt(hex.substring(2,4), 16);
let b = parseInt(hex.substring(4,6), 16);
let result = 'rgba('+r+','+g+','+b+','+alpha+')';
return result;
}
$se.colorAlphaSetHex = function(rgba){
if(rgba.search("rgb") == -1){
return rgba;
}else{
rgba = rgba.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+))?\)$/);
function hex(x){
return ("0" + parseInt(x).toString(16)).slice(-2);
}
return "#" + hex(rgba[1]) + hex(rgba[2]) + hex(rgba[3]);
}
}
$se.colorAlphaSetRgbaHex = function(rgba){
let parts = rgba.substring(rgba.indexOf("(")).split(","),
r = parseInt(trim(parts[0].substring(1)), 10),
g = parseInt(trim(parts[1]), 10),
b = parseInt(trim(parts[2]), 10),
a = parseFloat(trim(parts[3].substring(0, parts[3].length - 1))).toFixed(2);
function trim(str){
return str.replace(/^\s+|\s+$/gm,'');
}
return ('#' + r.toString(16) + g.toString(16) + b.toString(16) + (a * 255).toString(16).substring(0,7));
}
$('#smtEditIframe').on( 'load', function() {
window.smt = new Smtedit();
smt.init();
} );
Comments
키워드 내용에 대한 서로의 생각을 공유하고 댓글로 응원해주세요.