(在测试过程中,发现medium网站本身的脚本似乎都写得不是很稳定,使用过程中会出错,然后需要重新打开网页)
先看一下最终我完成的效果
1. 基础布局
思路:
- 外面一个container,大小全屏(或者占满父容器); 里面一个左右居中的story(文本内容区域),文本设置pading,
- story里一个title,一个p。 第一步完成后效果图:
2. 文本回车退格空格
- 文本可编辑的设置:
先对元素设置contenEditable=“true”,然后设置css使得获得焦点时的自带外边框消失;
[contenteditable] {
outline: 0px solid transparent;
/* 默认情况下鼠标是箭头,这里要让它变成插入符的样子 */
cursor: text;
}
- 设置文本占位符
设置可编辑区域的占位符有好些方法,这里写一种比较简单的方法。 方法来源
html元素添加自定义属性placeholder="占位符",
css代码:
[contentEditable=true]:empty:before {
content: attr(placeholder);
opacity: .6;
}
此时效果
- 回车退格后的段落增添和focus变动
分析:
(1)默认情况下,在可编辑区域按下回车键后页面会自动新建一个div,所以首先要禁止此行为;
(2)在按下回车键的元素后添加一个p。需要注意的是如果是在标题区域按下的回车,那么证明我们新增添了一个第一段,那么此时应该把正文区域placeholder放在第一段,把原来的placeholder去掉。
if (e.key == 'Enter') {
// 阻止默认自动产生新的div
e.preventDefault();
//新建p
const $newPara = document.createElement('p');
$newPara.setAttribute('contentEditable', 'true');
//如果是在标题后按退格,那么第一个p已经变成新的
//需要把第一个p的placeholder加上,之前p的相关属性去掉
const $fistPara = document.getElementsByClassName(STORY_PARA_FIST_CLASSNAME)[0];
$newPara.className = STORY_CONTENT_CLASSNAMES;
if ($curr.tagName == 'H3') {
$fistPara.classList.remove(STORY_PARA_FIST_CLASSNAME);
$fistPara.removeAttribute('placeholder')
$newPara.classList.add(STORY_PARA_FIST_CLASSNAME);
$newPara.setAttribute('placeholder', STORY_CONTENT_PLACEHOLDER);
}
$story.insertBefore($newPara, $curr.nextSibling);
$newPara.focus();
}
(3)按下delete键删除文字时,如果p内已无文字,则删除该p,focus到上一个p。如果仅存1个p,则不进行删除p的操作,而是回focus到标题区。
else if (e.key == 'Backspace' && $curr.tagName == 'P') {
if (!$curr.innerText) { //确认已经退格到头
const $previousEle = $curr.previousSibling;
// 当前最后一个p
if (!$previousEle.tagName) { //最后一个p时返回文本节点,tagName为undefined
focusAfterLastChar($storyTitle);
}
// p大于1个时
if ($previousEle.tagName == 'P') {
$story.removeChild($curr);
focusAfterLastChar($previousEle);
}
}
}
这里(2)(3)中的 focusAfterLastChar()方法是我自己定义的,目的是使focus位置能在文字的最后。
function focusAfterLastChar(ele) {
const textRange = document.createRange(); //获得一个范围是全doc的range
textRange.selectNodeContents(ele); //将range范围设定为要focus的p
textRange.collapse(false); // collapsed状态的range内容是空的,而代表dom树中的一个点,这里把这个点的位置设置为p的结尾;
const sel = window.getSelection(); //返回一个Seletion物件,代表当前用户选中或插入符的当前位置;
sel.removeAllRanges();
sel.addRange(textRange); //清除目前Seletion中的Range,把Range设定为前面设定的textRange
ele.focus();
}
记录:此时写完这部分后测试有几个问题;
- 如果汉字输入一半(拼音阶段)就按了回车,最后的几个字母会被放到下一行去。
- 如果是title后新建的p,这个p按退格回不到title
- 如果该文本区域已经没有文字,回退到上一区域时,上一区域会被删掉一个文字。
漏掉的效果:
- 如果p有文字,手动把插入符放在了p最开头,那么此时按退格后应该把这段文字和上一段文字合并;
解决:
问题(1): 首先尝试在新建p之后直接强制把innerText设置为空,发现没有效果(即使放在keydown监听回调函数最后一行也没用);原因分析:『自动填充下一行文字』是发生在keydown事件之后的。补充下面的代码,能起到把innerText设置为空发生在keydown事件之后的效果,从而达到我们的目标。
setTimeout(() => $newPara.innerText = '', 0);
问题(2) 分析:通过console.log可以发现新插入的p结点的前一个sibling是h3,而自己写的一个p结点的前一个是#Text,所以代码进行下面的修改
if (!$previousEle.tagName || $previousEle.tagName == 'H3') {
focusAfterLastChar($storyTitle);
}
问题(3) 分析:当该文本区无文字时,删除的操作并没有进行,而是会放到插入符变动到上一行后进行。所以我们直接阻止默认行为(删除行为)即可。
else if (e.key == 'Backspace' && $curr.tagName == 'P') {
if (!$curr.innerText) {
e.preventDefault(); // 加上这句
console.log('f');
漏掉的效果1
分析:我们需要检测如果「插入符在开头and p有内容」,那么就把该p去掉,并在上一个p或h3的文本基础上添加上当前p里的文本。
以上分析中最重要的就是:「如何检测插入符在开头」。
自己想出来的一种方法是:如果p有内容但按下del后p的值未发生变化,说明插入符在开头。经测试,添加打印文本代码,按下退格后,打印出来的是删除前的。所以我们要拿到删除后的值就需要用timeout来拿。
研究这段代码过程中发现的问题是:当回退后,插入符需要插在上一个区域原本内容的最后,但是当执行插入后插入符会自动变到最开头,也就是说更改文本区域内容会影响插入符位置。
if (isTitleElement($previousEle)) {
focusAfterLastChar($storyTitle);
setTimeout(() => { $storyTitle.innerText += textNew }, 3000)
//用以上代码进行测试,会发现执行插入后插入符会自动位于开头位置,
//如果插入文字与上面的focus对调,那么插入符会位于新文本最后,也不是我们想要的效果
最终在这里找到了答案。
// 把设置插入符的代码改为这个
function setCaret(ele, pos) {
const range = document.createRange(ele);
range.setStart(ele.childNodes[0], pos);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// 退格键时的处理
if (e.key == 'Backspace' && $curr.tagName == 'P') {
const $previousEle = $curr.previousSibling;
if ($curr.innerText) {
const textOriginal = $curr.innerText;
// 我们要让获取新文本内容发生在回调函数之后,所以加上setTimeout
let textNew;
setTimeout(() => {
textNew = $curr.innerText;
if (textNew == textOriginal) {
//如果删除后和之前无区别,则说明插入符在开头,退格后与上一区域合并文本;
if (isTitleElement($previousEle)) {
$curr.innerText = '';
$storyTitle.innerText += textNew;
setCaret($storyTitle, textOriginal.length);
} else {
$story.removeChild($curr);
$previousEle.innerText += textNew;
setCaret($previousEle, textOriginal.length);
}
}
}, 0);
}
if (!$curr.innerText) { //确认已经退格到头
e.preventDefault();
// 当前最后一个p
if (isTitleElement($previousEle)) {
setCaret($storyTitle, $storyTitle.innerText.length);
}
// p大于1个时
if ($previousEle.tagName == 'P') {
$story.removeChild($curr);
setCaret($previousEle, $previousEle.innerText.length);
}
}
在这个过程中,发现还漏掉了一个效果是:
漏掉的效果2:插入符在文字中间或开头时,按下回车后不仅要插入新的p,插入符后面的文字也应该被挪到下一区域去。
思路:按下回车时依旧需要判定插入符位置; 前面写效果1时网络上查判定插入符位置的代码非常复杂,自己的逃避心理使得自己想了一个更简单的解决方法,但是当按下的是回车键时,前面的方法就无法使用,看来还是需要写一个通用的判断插入符位置的函数才行... 在网络上找到了下面这段函数可以直接拿来用。
function getCaretCharacterOffsetWithin(element) {
var caretOffset = 0;
var doc = element.ownerDocument || element.document;
var win = doc.defaultView || doc.parentWindow;
var sel;
if (typeof win.getSelection != "undefined") {
sel = win.getSelection();
if (sel.rangeCount > 0) {
var range = win.getSelection().getRangeAt(0);
var preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
caretOffset = preCaretRange.toString().length;
}
} else if ( (sel = doc.selection) && sel.type != "Control") {
var textRange = sel.createRange();
var preCaretTextRange = doc.body.createTextRange();
preCaretTextRange.moveToElementText(element);
preCaretTextRange.setEndPoint("EndToEnd", textRange);
caretOffset = preCaretTextRange.text.length;
}
return caretOffset;
}
回车键部分代码
if (e.key == 'Enter') {
// 阻止默认自动产生新的div
e.preventDefault();
//新建p
const $newPara = document.createElement('P');
$newPara.setAttribute('contentEditable', 'true');
//如果是在标题后按退格,那么第一个p已经变成新的
//需要把第一个p的placeholder加上,之前p的相关属性去掉
const $fistPara = document.getElementsByClassName(STORY_PARA_FIST_CLASSNAME)[0];
$newPara.className = STORY_CONTENT_CLASSNAMES;
if ($curr.tagName == 'H3') {
$fistPara.classList.remove(STORY_PARA_FIST_CLASSNAME);
$fistPara.removeAttribute('placeholder')
$newPara.classList.add(STORY_PARA_FIST_CLASSNAME);
$newPara.setAttribute('placeholder', STORY_CONTENT_PLACEHOLDER);
}
//判定插入符的位置,如果在中间,则需要把插入符之后的部分挪到下一个p里
const len = $curr.innerText.length
const caretPos = len == 0 ? 0 : getCaretCharacterOffsetWithin($curr);
setTimeout(() => {
if (caretPos == len) {
// 如果中文输入到一半就按回车,拼音会在keydown回调函数之后被放到下一行
// 所以我们需要将innertext在之后执行,所以需要放到settimeout里
$newPara.innerText = ''
} else {
$newPara.innerText = $curr.innerText.substring(caretPos, len);
$curr.innerText = $curr.innerText.substring(0, caretPos);
}
$story.insertBefore($newPara, $curr.nextSibling);
// 此时插入符在开头就可以了
$newPara.focus();
}, 0)
}
补充:还修正了一个问题,上面退格时要调用setCaret函数,这块有点问题
function setCaret(ele, pos) {
// 如果前一个区域里无文字,直接调用focus即可,不然下面的ele.childNodes会是空的,会报错
if (ele.innerText.length == 0) {
ele.focus();
}
const range = document.createRange(ele);
range.setStart(ele.childNodes[0], pos);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
3. 添加每行文本前的提示符
要实现的效果:
- 每行文本为空时,出现下面的加号按钮,输入文字后加号按钮消失
2. 标题旁边有左边界线,但当focus不在标题时,左边界线消失;
- 标题不为空时,出现title字样
- 退格后如果还在该段,而且该段文本为空,加号按钮出现在该段,如果退到上一段,且上一段为空,则加号按钮出现在上一段。
- 回车后如果下一段为空,则加号按钮出现在下一段。
细节样式:
-
插入符颜色是灰色,不是默认的黑色 实现这个很简单,css直接就有现成的属性
caret-color可以使用 -
去掉拼写自动纠正下面的红色虚线 直接用元素属性
spellcheck="false"即可,可以把它放在父容器上。
11.13更新
发现昨天代码中有错误,又进行了修改。然后添加了一些注释。然后接着写代码。
- 先用伪元素实现标题框前的边框和title字样
/* 标题为空时不显示title */
.story__title:empty::after {
content: '';
}
/* 标题focus时才显示边界线 */
.story__title:focus::after {
opacity: 1;
}
- 写出加号按钮的css和html基本样式
- 实现点击到一行时,加号按钮移动到该行,基本思路是:当某一行被focus时,获取被focus的元素的top值和left值,然后据此设定加号按钮的top和left值。
const DISTANCE_FROM_TEXT = -60;
$inlineTooltip.style.top = $curr.offsetTop + 'px';
// 左边距离行文本元素60px;
$inlineTooltip.style.left = $curr.offsetLeft + DISTANCE_FROM_TEXT + 'px';
其他问题: (1)实现仅在focus时才显示tooltip =>
focus是无法冒泡到父容器的,所以我们只能一个个绑定,但因为不断有删除新增元素,所以一个个文本块绑定focus事件效率并不高。而且退格到头等事件还是得自己添加显示按钮的代码。 我想的是一种方法是直接click事件绑定父元素。如果点击的是p或h3,说明是focus的是文本区域。然后回车和退格事件补充相应代码。 但是还有一个问题是当文本内开始有文字时(监听input事件),tooltip又要消失。 所以无论如何还是需要给p一个个绑定事件(focus和input事件)。
(2)在小屏幕中加号按钮和title旁的边界线是不显示的。这点直接用媒体查询即可=>
@media only screen and (max-width: 879px) {
.inlineTooltip {
opacity: 0 !important;
}
.story__title:focus::after {
opacity: 0 !important;
}
}
(3)resize之后加号按钮的位置需要跟着动,观察medium可发现,medium网站上加号位置的调整是有一个滞后时间的。
思路:
监听resize,然后在setTimeout回调函数中更新button的位置。 重点是要记录button旁边的文本区域是哪个元素。我想到的方法有:给目标元素增加class,或者是直接用一个变量记录这个元素。我选择用后者实现。
function showInlineTooltip(ele) {
.....
// 记录当前左边显示加号按钮的元素
currTooltipLocation = ele;
}
window.onresize = function() {
if (currTooltipLocation && $inlineTooltip.style.opacity == 1) {
// update the location of inlineTip
}
}
(4)输入中文时拼音过程中加号按钮不消失。=>
解决:用isComposing来记录文字是否正在拼写中
$storyTitle.addEventListener('compositionstart', (e) => {
isComposing = true;
})
$storyTitle.addEventListener('compositionend', (e) => {
isComposing = false;
})
在input监听时,如果isComposing是true,则什么也不做,直接return。
(5) 内容为iconfont的button鼠标指针有点奇怪,加入cursor:pointer后iconfont部分指针不发生改变。
解决:加入pointer-events: auto;
4 实现每行文字前的按钮点击后出现菜单
步骤:
(1)点击按钮后按钮旋转90度
这里我在js中设定:当菜单展开时添加一个表示展开的class,然后给这个class添加一个旋转效果,当菜单关闭时再添加一个表示关闭状态的class,然后也给这个class添加一个旋转效果。
$inlineTooltipBtn.addEventListener('click', () => {
// 添加相应类名以添加动画效果
if ($inlineTooltipBtn.classList.contains('isActive')) {
$inlineTooltipBtn.classList.remove('isActive');
$inlineTooltipBtn.classList.add('nonActive');
} else {
$inlineTooltipBtn.classList.add('isActive');
$inlineTooltipBtn.classList.remove('nonActive');
}
})
相应css
.isActive {
transition: all .2s;
transform: rotate(45deg);
}
.nonActive {
transition: all .2s;
transform: rotate(-90deg);
}
(2)html和css中设置好会出现的菜单样式(目标效果如下)并添加功能。
这几个按钮分别是: 插入图片,在unsplash上搜索图片并插入(这个按钮我会去掉)、插入视频地址、插入嵌入式内容地址、插入代码块、插入分隔符。
我决定暂且只做插入图片功能,其他的有额外时间再考虑做。
- 图片按钮和功能
做这里的时候遇到一个问题是发现我的按钮会像透明效果一样,解决方法是给按钮容器添加z-index属性
点击后出现文件上传窗并上传图片到新建的img元素中=>
首先需要再网页中添加一个input,研究了一下medium网页,发现它是把这个input设置为left: -9999px; top: -9999px;以让它在网页中不可见;我直接设置为了hidden。
$inlineImgbtn.addEventListener('click', () => {
console.log($currTooltipLocation.offsetTop);
// 打开文件浏览窗并上传
$imgInput.click();
$imgInput.addEventListener('change', (e) => {
const reader = new FileReader();
const $newImg = document.createElement('img');
$newImg.classList.add('story__img');
$story.insertBefore($newImg, $currTooltipLocation);
reader.onload = () => {
$newImg.src = reader.result;
}
reader.readAsDataURL(e.target.files[0]);
// 更新inline tooltip的位置
// 这里需要稍等一点时间才能真正更新位置,不然会出现在图片一半的位置
setTimeout(() => {
//使tooltip呈现关闭状态
$inlineTooltipBtn.click();
setInlineToolTipLocation($currTooltipLocation);
}, 50);
})
})
分析了下medium,图片元素会有边框,并且hover和点击后的效果不同,点击图片后下方会出现一个可以填写图片说明的文本框,上方会出现一个填alt text的地方。后者我就不做了,我只做边框和填写图片说明的文本框部分。
边框不用多说,但是要注意给图片添加tabindex=0属性,这样它就可以被focus了,填写文本说明部分也是用js插入一个figcaption元素。
中间出现的小问题是:
- 图片和caption之间自带了空白,将图片改为block可解决这个问题;
- medium上的效果是当点击了一张图片后,其他的图片的caption如果没有填写的话,caption元素会隐藏,需要再点击相应图片,caption元素才会再次出现。(这个功能感觉不是很必要,暂且去掉)
11月14日更新
- 解决标题区域图片插入后问题;=>判断如果是标题区域,则把图片和figcaption插在第一段之前)
- 页面刚打开时,标题区域退格后inline tooltip是自动打开的。/ 点击p之后tooltip也是自动打开的。 -> 初始化未设置menu为display:none。
- tooltip覆盖问题未完全解决。 => 在tooltip menu的方法里将placeholder进行修改,打开menu则placeholder消失。
5 实现全选文字后出现字符样式tooltip
-
实现tooltip基本html和css 实现基本的tooltip在w3c shool直接就有教程,可以参考制作,按钮样式直接用medium上的svg图案。但这里的排版一直有些问题,我最后算是hard code出来的。主要还是对svg特性不够熟悉。
-
实现全选文字后弹出tooltip
思路:结合mouseup事件和window.getSelection
一开始写出来的是这样。在每次按下鼠标后监测何时释放鼠标,记录下按下和释放的鼠标坐标,然后为了让tooltip在选中文字的中间,tooltip的左边距离=选中文字左边界的left值+ 选中文字长度的一半 - tooltip长度的一半。
这样写能实现一个最基本的效果,但有一些问题。
$story.addEventListener('mousedown', (e) => {
const mouseDownPos_x = e.clientX;
const mouseDownPos_y = e.clientY;
$storyTitle.addEventListener('mouseup', (e) => {
if (window.getSelection && window.getSelection().toString().length > 0) {
console.log(e.target.style.cursor);
console.log('dis')
$styleTooltip.style.visibility = 'visible';
const mouseUpPos_x = e.clientX;
const mouseUpPos_y = e.clientY;
const x_small = mouseDownPos_x > mouseUpPos_x ? mouseUpPos_x : mouseDownPos_x;
const xDiffHalf = mouseDownPos_x > mouseUpPos_x ? (mouseDownPos_x - mouseUpPos_x) / 2 : (mouseUpPos_x - mouseDownPos_x) / 2;
const widthHalf = $styleTooltip.offsetWidth / 2;
$styleTooltip.style.left = x_small + xDiffHalf - widthHalf + 'px';
} else {
$styleTooltip.style.visibility = 'hidden';
}
})
})
问题:
(1)使tooltip居中于选中文字的办法不稳定,有时正确,有时不正确。这里其实应该集合6一起,因为还要实现文字的变动,所以用textrange似乎更能实现效果。这里决定先学习一下
(2)当mouseup时鼠标不是caret时,利用window.getSelection无法判定文字是否被选中,导致tooltip无法出现。但medium网页上即使cursor不是caret,只要能选中文字,tooltip就能正常出现。
** 11月26日更新**
参考了css-tricks.com/how-to-crea… 其实这篇文章思路和我是一样的。例如left计算思路是下图这样: 要求的left = left + a - b,上面我也是这样的思路,但不知为何结果很奇怪
这次能实现tooltip始终出现在文字中间。
// 给story部分增加pointerup时间,以实现选择文字后出现style tooltip的目的。
$story.addEventListener('pointerup', (e) => {
// 重置前面的tooltip(这块后面再改)
// 在这个过程中如果发生resize的话,还是要继续调整
$styleTooltip.style.visibility = 'hidden';
let selection = document.getSelection();
let text = selection ? selection.toString() : null
console.log(text)
if (text && text.trim() != '') {
$styleTooltip.style.visibility = 'visible';
let rect = selection.getRangeAt(0).getBoundingClientRect();
$styleTooltip.style.top = `calc(${rect.top}px - 70px)`;
// styletooltip中心位置计算方式
// 105是tooltip宽度的一半
$styleTooltip.style.left = `calc(${rect.left}px + calc(${rect.width}px / 2) - 105px)`;
}
})
不过目前还是有一些问题,例如出现一次tooltip后,有时会出现无法选中的情况。
- 加上弹出tooltip时的动画效果
6 实现文字样式变动
一些启发: 一些功能不要孤立地看,一定要提前分析好全部功能,并决定好哪些先做,哪些后做更好。