65.9K
CodeProject 正在变化。 阅读更多。
Home

具有实时预览和样式 Playground 的 HTML 编辑器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2019 年 9 月 15 日

CPOL

15分钟阅读

viewsIcon

20373

downloadIcon

393

使用实时预览以及样式“属性网格”编辑标记、样式和 Javascript。

目录

引言

我一直对编写一个具有特定功能的 HTML 编辑器很感兴趣,即预览窗口以及用于查看/操作各种元素的属性和特性的属性面板。在撰写文章 《趣谈 Div 和 Table 布局》 后,似乎很自然地将这篇文章用于实际应用,所以我们在这里。

这里采用的主要讨论点和有趣技巧是

  • 创建具有编辑器、预览和属性部分的水平和垂直布局的编辑器。
  • 在编辑器中进行更改时更新预览。
  • 同步两个编辑器“视图”之间的属性值。
  • 在属性面板中同时提供“悬停元素”和“选择并锁定元素”功能。
  • 当用户更改布局(水平/垂直)时,跟踪当前选定的元素。
  • 常用标记的快捷方式,因为整篇文章都是使用此 HTML 编辑器编写的!
  • 元数据中定义的配置属性/特性。
  • 用于生成属性部分的动态 HTML。
  • 重置 CSSOM,以便预览中的样式反映编辑器中 <style> 部分的更改。

待办事项列表中的有趣内容

这些是我希望继续探索但又脱离这里提出的“基本”编辑器概念的事物,因为像令牌着色这样的功能将需要一个真正的编辑器,而不仅仅是这里使用的 textarea 元素。

  • 真正的菜单栏
  • 元素标签自动完成
  • 着色
  • 三个窗格(编辑器、预览、属性)的可调节宽度/高度
  • 更高级的键盘快捷键。 Mousetrap 看起来是一个不错的库可以用来实现这个功能。
  • 保存和缺失的加载很糟糕。我不喜欢保存会将文件放入下载区域。
  • 视图作为对象(参见下面的“HTML 不是对象”部分)
  • 搜索和替换

关于 CSS 的一句话

在 Code Project 的讨论和撰写本文的过程中,我意识到 CSS 有不同的用途

  1. 定义元素的多个属性,这些属性可以被视为可重用元素,尤其是在它通常有子元素的情况下
  2. 定义一个特定的样式来定制该元素
  3. 将标签定义为用于操作元素的类或 ID
  4. 视觉(相对于布局)样式,如颜色和字体大小
  5. 动画
  6. 其他?

尤其重要的是,您**永远、永远**不想将一个标签(通常是一个类)同时用于布局样式和选择。即使在页面开发初期,这似乎也是一个完全合理的做法,但当您需要自定义布局时,它可能会变成一场噩梦,因为您突然需要两个不同的类标签,但操作元素的行为却与描述布局的类标签绑定在一起。

层叠样式

好吧,我讨厌层叠样式,原因很简单,如果改变标记的结构,它就会使 CSS 的层叠结构失效。我更喜欢扁平的 CSS,其中(通常)类标签描述了应用于 HTML 结构中该元素的样式。

键盘快捷键

在此编辑器中,我使用 ALT 键映射了多个键盘快捷键。Chrome(以及其他浏览器)中的某些扩展可能会被触发。至少在 Chrome 中,您可以转到 chrome://extensions/shortcuts 并更改键盘映射(或禁用它们),如果您发现它们与此编辑器的快捷键冲突。即使使用 event.preventDefault() 也无法阻止具有快捷键的扩展接管!

Bug

我偶尔注意到一些奇怪的事情

  • “用标记环绕选中文本”有时不会选择全部选中文本。原因不明。
  • 预览编辑器不会自动滚动到您在编辑器中编辑文本的位置。这是一个特别恼人的问题,很难弄清楚如何跟踪用户在编辑器中的位置并定位滚动条,以便用户可以看到预览。我能想到的唯一修复方法是找出包裹用户键入位置的当前元素,并使用 scrollIntoView()。这似乎很复杂,因为需要创建一个自定义标签来标识元素,然后再删除它。我在切换水平和垂直布局时,当选中一个元素时,已经使用了这个技巧。
  • 我用双空格开始新句子,这是很久以前在学校学的。编辑器需要将多余的空格替换为 &nbsp;,否则它将被渲染为单个空格。
  • 粘贴到编辑器后,预览不会更新。您必须按一个键,例如其中一个光标键,才能看到您粘贴的内容。还没来得及修复。

HTML 不是“对象”

如果我将此作为 WinForm 应用程序编写,我可以轻松地将 TextBox 分配给“其他”布局面板作为子控件,所有文本、选择、事件等都将随之移动。HTML 不是这样的。在此编辑器的布局中,您会看到我复制了编辑器、预览和属性“控件”,但这表示它们需要单独的事件处理程序,并在用户在 v/h 布局之间切换时进行适当更新。这很烦人,可以通过一种将 HTML 部分视为真实对象的机制来解决。这在此阶段过于复杂,无法实现。

值得注意

我完全使用 Visual Studio Code 中的 Quick HTML Previewer 编写了这个编辑器,它可以处理 HTML、CSS 甚至 JavaScript,甚至导入 jQuery。我使用 Chrome 进行调试,但 Quick HTML Previewer 的功能令人印象深刻!

另外值得注意的是,是的,我正在使用 jQuery。它只是使用起来更容易,尤其是在操作具有相同类标签的多个元素时。

屏幕截图

横向布局

纵向布局

代码深入剖析 - 基本结构

那么让我们来看看幕后。

HTML 布局

首先要看的是 HTML 的布局。水平(并排)结构如下所示

<div class='__fullscreen __hidden' id='__horizontalView'>
    <div class='__parent __toolbar'>
        <div class="__child-middle-left __buttonBar"></div>
    </div>
    <table class='__noborders __fixed __fill'>
        <tr>
            <td class='__editorhw'>
                <div class='__h100p __border1'>
                    <textarea class='__taeditorh' id='__editorh'></textarea>
                </div>
            </td>
            <td class='__mw1px'>
                <div class='__preview __h100p __border1' id='__previewh'></div>
            </td>
            <td class='__properties'>
                <div class='__h100p  __border1 __vscroll'>
                    <div class='__bothPropertiesContainer __propertiesContainerh'></div>
                </div>
            </td>
        </tr>
    </table>
</div>

与纵向布局形成对比,在纵向布局中,编辑器和预览是上下排列的,属性部分在右侧

<div class='__fullscreen' id='__verticalView'>
    <table class='__noborders __fill'>
        <tr style='height:1px;'>
            <div class='__parent __toolbar'>
                <div class="__child-middle-left __buttonBar"></div>
            </div>
        </tr>
        <tr>
            <td>
                <table class='__noborders __fill'>
                    <tr>
                        <td>
                            <div class='__h100p __border1'>
                                <textarea class='__taeditorv' id='__editorv'></textarea>
                            </div>
                        </td>
                    </tr>
                    <tr>
                        <td class='__mw1px'>
                            <div class='__preview __border1 __h100p' id='__previewv'></div>
                        </td>
                    </tr>
                </table>
            </td>
            <td class='__properties'>
                <div class='__h100p __border1 __vscroll'>
                    <div class='__bothPropertiesContainer __propertiesContainerv'></div>
            </td>
        </tr>
    </table>
</div>  

没有什么太大的不同,是吗?一些值得注意的地方

  • 所有类和 ID 标签都以 __ 作为前缀。为什么?因为我不希望编辑器中使用的标签与用户可能为自己的标记创建的标签冲突。从技术上讲,我也应该为函数加上 __ 前缀,但我在某个时候计划将它们包装在 class 容器中,所以这样做的意义就变得有些无关紧要了。
  • 请注意,我们有两个编辑器、预览和属性容器,它们通过后缀 hv 来区分。这里的命名约定是非常故意的,因为有一些函数依赖于这些特定的后缀。
  • 请注意,一些类标签是布局类的,而另一些类标签用于选择元素。

模板

使用了各种模板来填充具有动态数据的视图,以及复制通用功能,即顶部的工具栏。

工具栏模板

<div class='__hidden' id='__sourceButtonBar'>
    <button class='__showHorizontal'>Horizontal</button>
    <button class='__showVertical'>Vertical</button>
    <button class='__clearEditors'>Clear</button>
    <button class='__save'>Save</button>
</div>

此模板仅复制到水平和垂直视图中。

元素模板

<div class='__hidden' id = '__sourcePropertiesContainer'>
    <div class='__propertyItem'>Element:
         <span class='__text-bold __elementTagName'></span></div>
    <div class='__propertyItem'>
         <span class='__propertyLabel'>Name:</span>
         <input class='__inputPropertyField __propertyName' id='__pname{hv}'></div>
    <div class='__propertyItem'><span class='__propertyLabel'>ID:</span>
         <input class='__inputPropertyField __propertyId' id='__pid{hv}'></div>
    <div id='__sections'></div>
</div>

此模板用于属性容器的最顶部,负责渲染选定的元素及其 IDname。例如,给定以下标记(直接在编辑器中编码)

这是一个演示段落。

我们在属性容器中看到

上面段落的标记如下所示

<p id='myParagraph' name='fizbin'>This is a demo paragraph.</p>

属性部分模板

属性按部分组织,并带有标题。这些是使用此模板从元数据动态创建的

<div class='__hidden' id = '__sectionTemplate'>
    <div class='__propertItem __section __sectionBar __sectionName{sectionName}'>
     {sectionName}</div>
</div>

请注意 {sectionName} 字段的使用,在生成属性容器时,它将被替换为实际的节名。

输入样式模板

大多数样式属性只是一个输入框,使用此模板作为生成标签和输入元素的依据

<div class='__hidden' id = '__inputStyleTemplate'>
    <div class='__propertyItem'><span class='__propertyLabel'>{name}:</span>
    <input class='__inputPropertyField'
    id='__inputProperty{name}{hv}' cssName='{name}'></div>
</div>

请注意 {name}{hv} 字段的使用,它们用于标识和 ID 特定属性样式名称以及它属于水平还是垂直视图。

复选框样式模板

某些样式属性在技术上没有值——它们的存在决定了元素的状态。对于这些,有一个复选框模板

<div class='__hidden' id = '__checkboxStyleTemplate'>
    <div class='__propertyItem'><span class='__propertyLabel'>{name}:</span>
    <input class='__checkboxPropertyField' id='__inputProperty{name}{hv}'
     type='checkbox' cssName='{name}'></div>
</div>

元数据

元数据确定每个节中的属性节和特定的样式属性。元数据还确定了应为特定元素显示哪些节。请注意,这些定义**并不全面**。根据您的需求进行添加。首先是节及其样式

var sections = [
        {name:'Dimensions', styles: ['width', 'height', 'max-width', 'max-height']},
        {name:'Margins', styles: ['margin-top', 'margin-right',
         'margin-bottom', 'margin-left']},
        {name:'Padding', styles: ['padding-top', 'padding-right',
         'padding-bottom', 'padding-left']},
        {name:'Font', styles: ['font-family', 'font-size', 'font-weight', 'font-style']},
        {name:'Border', styles: ['border-style', 'border-width',
         'border-color', 'border-radius']},
        {name:'Flags', styles: [{style:'readonly', control:'checkbox'}]}
    ];

请注意,Flags 部分的格式略有不同,它指定了用于呈现样式选项的控件。默认情况下,控件假定为 input 元素。

然后将元素标签映射到该元素的所需部分

var tagSections = [
    {tag: 'input', sections: ['Margins', 'Padding', 'Flags']},
    {tag: 'p', sections: ['Dimensions', 'Margins', 'Padding', 'Font', 'Border']},
    {tag: 'div', sections: ['Dimensions', 'Margins', 'Padding', 'Border']},
    {tag: 'ul', sections: ['Margins', 'Padding', 'Border']},
    {tag: 'li', sections: ['Margins', 'Padding', 'Border']},
];

同样,这显然不是一个全面的列表!自己添加!

全局变量

糟糕。全局变量。这些在整个编辑器代码中使用,应该放在一个 static 类中。我也会在某个时候审查它们,看看它们是否都必要。目前,它们用于维护编辑器的状态。

var altKeyDown = false;
var ctrlKeyDown = false;
var hoverElement = undefined;
var selectedElement = undefined;
var currentEditor = undefined;
var currentPreview = undefined;

// For preserving section content visibility when we click on tags.
var sectionContentVisible = [];

代码深入剖析 - 初始化

既然我们已经涵盖了结构基础,让我们来看看初始化过程,以了解模板和元数据如何用于填充水平和垂直布局。核心初始化例程是

$(document).ready(() => initialize());

function initialize() {
    initializeSections();
    setupButtonBar();
    setupSourcePropertiesContainer();
    setupPropertiesContainer('h');
    setupPropertiesContainer('v');
    wireUpEvents();
    showHorizontalLayout();
    demo();
}

部分初始化

评论说明了一切

// The purpose of this is to take the simpler (more human friendly) version of this:
// {name:'Dimensions', styles: ['width', 'height', 'max-width', 'max-height']},
// and convert the styles to an array of objects that looks like this:
// styles: [{style:'width', control:'input'}]
// That way we can get rid off all the horrid if-else checks
// when dealing with the style formatting.
function initializeSections()
{
    sections.forEach(s => {
        let i = 0;
        for (i = 0; i < s.styles.length; i++) {
            if (typeof(s.styles[i]) == 'string') {
                s.styles[i] = {style: s.styles[i], control: 'input'};
            }
        }
    });
}

我最初编写的代码有很多 if-else 语句,基于样式列表中是否定义了 control 键。依我看,这很难看,所以我决定将样式“映射”到一个通用格式,基于样式本身是 string 还是 object,所以这个

styles: ['width', 'height', 'max-width', 'max-height']

和这个

styles: [{style:'readonly', control:'checkbox'}]

被统一到后一种形式,其中默认控件(string 数组)被映射到一个对象

{style: [stylename], control: 'input'}

这统一了每个部分的样式处理,但当样式列表只是一个 string 数组时,它使程序员更容易定义样式。

设置按钮栏

非常简单——只需将按钮栏模板复制到水平和垂直视图中

function setupButtonBar() {
    let html = $('#__sourceButtonBar').html();
    $('.__buttonBar').html(html);
}

jQuery 的乐趣——模板 HTML 被复制到类名为 __buttonBar 的元素的每个实例中。

设置属性容器

属性容器实际上是一个模板,其中填充了节和节样式“input”控件,然后将其复制到水平和垂直视图中。

function setupSourcePropertiesContainer() {
    let sectionNames = sections.map(s => s.name);

    let sectionTemplate = $('#__sectionTemplate').html();
    let sectionContent = [];
    let sectionNameClasses = [];

    sectionNames.forEach(n =>
    {
        let sectionName = '__sectionName' + n;
        let contentName = '__content' + n;
        let st = sectionTemplate.replaceAll('{sectionName}', n);
        st = st + "<div class='" + contentName + "'>";
        st = createSectionStyleTemplate(sections.filter(s => s.name == n)[0].styles, st);
        st = st + "</div>";
        sectionContent.push(st);
        sectionNameClasses.push({section: '.' + sectionName, content: '.' + contentName});

        // All section content initially visible
        sectionContentVisible['.' + contentName] = true;
    });

    $("#__sections").html(sectionContent.join(''));

    wireUpSectionEvents(sectionNameClasses);
    wireUpSectionStyleEvents(sections);
}

我们在这里做了一些事情

  1. 我们只需将令牌 {sectionName} 替换为节索引,完整的名称 __sectionName[n],它稍后用于连接单击节名称的事件,该事件会折叠或展开节内容。
  2. 创建一个 div 来容纳节内容——样式名称和“input”控件。
  3. 所有节都标记为初始可见。
  4. 然后将节及其内容连接起来,并用此替换 __sections ID 的 div 中的存根。

创建节模板继续解析定义每个节中样式的元数据

function createSectionStyleTemplate(styles, template) {
    styles.forEach(s =>
    {
        let overrideTemplate = $('#__' + s.control + 'StyleTemplate').html();
        template = template + overrideTemplate.replaceAll('{name}', s.style);
    });

    return template;
}

在这里,获取特定的输入模板,此时是 inputcheckbox,并将令牌 {name} 替换为样式名称。

最后,将处理用户在节上按 Tab 键或按 ENTER 键接受更改的事件连接起来。由于节是动态生成的,我们必须使用 documenton 事件

function wireUpSectionEvents(sectionNameClasses) {

    sectionNameClasses.forEach(sni =>
    {
        // When clicking on the section div, show or hide the content.
        // This doesn't work:
        // $(sni.section).on('click', () => showOrHideContent(sni.content));
        // We have to wire this up at the document level and pass in the selector!
        // See: https://stackoverflow.com/a/29674985/2276361
        $(document).on('click', sni.section, () => showOrHideSectionContent(sni.content));
    })
}

function wireUpSectionStyleEvents(sections) {
    // Also wire up future property style input boxes and checkbox events.
    sections.forEach(section =>
    {
        section.styles.filter(s=>s.control=='input').forEach(sectStyle =>
        {
            let inputElement = '#__inputProperty' + sectStyle.style;

            $(document).on('keydown', inputElement + 'h', onInputKeyPress);
            $(document).on('blur', inputElement + 'h', onUpdateElementStyle);

            $(document).on('keydown', inputElement + 'v', onInputKeyPress);
            $(document).on('blur', inputElement + 'v', onUpdateElementStyle);
        });

        section.styles.filter(s=>s.control=='checkbox').forEach(sectStyle =>
        {
            let inputElement = '#__inputProperty' + sectStyle.style;
            $(document).on('click', inputElement + 'h', onCheckbox);
            $(document).on('click', inputElement + 'v', onCheckbox);
        });
    });
}

一天结束时,我们执行诸如折叠节之类的事情

以及制表(blur)离开输入框或按 ENTER 键来接受更改。

一旦属性部分模板被初始化,它就会被复制到特定的视图中。回想一下

setupPropertiesContainer('h');
setupPropertiesContainer('v');

这是一个简单的函数

function setupPropertiesContainer(hv) {
    let html = $('#__sourcePropertiesContainer').html();
    // Resolve the final dynamic ID with h or v to distinguish which property
    // is being changed.
    html = html.replaceAll('{hv}', hv);
    $('.__propertiesContainer' + hv).html(html);
}

请注意,{hv} 令牌的替换在这里完成。

文档事件

还处理一些文档范围内的事件,这些事件会做一些我稍后将描述的花哨的事情。这些行为就这样连接起来

function wireUpEvents() {
    $('#__editorh').keyup(event => editorKeyPress(event, '#__editorh', '#__previewh'));
    $('#__editorv').keyup(event => editorKeyPress(event, '#__editorv', '#__previewv'));
    $('#__editorh').keydown(event => editorKeyDown(event, '#__editorh', '#__previewh'));
    $('#__editorv').keydown(event => editorKeyDown(event, '#__editorv', '#__previewv'));
    $('#__editorh').on('paste', event => onPaste(event, '#__editorh', '#__previewh'));
    $('#__editorv').on('paste', event => onPaste(event, '#__editorv', '#__previewv'));
    $('.__preview').mouseover(event => previewMouseOver(event.target));
    $('.__preview').mouseleave(event => clearProperties());
    $('.__preview').click(event => previewClick(event));
    $('.__showHorizontal').click(() => showHorizontalLayout());
    $('.__showVertical').click(() => showVerticalLayout());
    $('.__clearEditors').click(() => clearEditors());
    $('.__save').click(() => save());

    // Handle CR
    $('.__propertyName').keypress((event) => propertyNameKeyPress(event));
    $('.__propertyId').keypress((event) => propertyIdKeyPress(event));

    // Handle lose focus
    $('.__propertyName').blur((event) => {
        updateElementName(event);
        updateSource();
    });

    $('.__propertyId').blur((event) => {
        updateElementId(event);
        updateSource();
    });
}

更多元数据 - 自定义键

在元数据中还定义了指定自定义键行为的能力

keymap = [
    {special: 'alt', key: 'C', insert: ['<code>', '</code>'] },
    {special: 'alt', key: 'P', insert: ['<p>', '</p>'] },
    {special: 'alt', key: 'R',
     insert: ['<p>&nbsp;', '</p>'], eoi: true}, // cursor at end of insert
    {special: 'alt', key: 'O', insert: ['<ol>', '</ol>'] },
    {special: 'alt', key: 'U', insert: ['<ul>', '</ul>'] },
    {special: 'alt', key: 'L', insert: ['<li>', '</li>'] },
    {special: 'alt', key: 'B', insert: ['<b>', '</b>'], toggle: true },
    {special: 'alt', key: 'I', insert: ['<i>', '</i>'], toggle: true },
    {special: 'alt', key: '1', insert: ["<pre lang='cs'>", '</pre>'] },
    {special: 'alt', key: '2', insert: ["<pre lang='jscript'>", '</pre>'] },
    {special: 'alt', key: '3', insert: ["<pre lang='html'>", '</pre>'] },
    {special: 'alt', key: '4', insert: ["<pre lang='css'>", '</pre>'] },
];

请注意,这里所有这些都通过 alt 键组合进行连接,以避免可能与 ctrl 或其他键组合相关的浏览器行为。在撰写本文时,我发现这些快捷键确实简化了写作过程——我不再手动输入 codepre 标签,并将 alt 1-4 键映射到特定的 lang 选项非常棒!

演示初始化

最后,回想一下 initialize() 函数中的最后两行

showHorizontalLayout();
demo();

这些设置了初始编辑器布局,并在编辑器中放置了一些内容,以便您可以立即开始尝试。为了完整起见,我将展示控制您正在使用的布局的三种函数以及清除布局的函数

function showVerticalLayout() {
    copy('h', 'v');
    $('#__verticalView').removeClass('__hidden');
    $('#__horizontalView').addClass('__hidden');
    currentEditor = '#__editorv';
    currentPreview = '#__previewv';
}

function showHorizontalLayout() {
    copy('v', 'h');
    $('#__verticalView').addClass('__hidden');
    $('#__horizontalView').removeClass('__hidden');
    currentEditor = '#__editorh';
    currentPreview = '#__previewh';
}

function clearEditors() {
    $('#__editorh').val('');
    $('#__editorv').val('');
    $('#__previewh').html('');
    $('#__previewv').html('');
}

最后,初始化编辑器

function demo() {
    let demoText="<style>\n
        p {\n
           margin: 0px 0px 0px 5px;\n
          }\n
        .mydiv {\n
           background-color: red;\n
           margin: 5px;\n
        </style>\n
        <p id="hi">Hello World!</p>
        <div class="mydiv" id="div">DIV Content</div>
        <input value="An Input">";
    $('#__editorv').val(demoText);
    $('#__previewv').html(demoText);
    $('#__editorh').val(demoText);
    $('#__previewh').html(demoText);
}

代码深入剖析 - 事件处理程序

编辑器的真正核心在于事件处理程序,所以我们来看看它们。

悬停和选择元素

选择一个元素是编辑其样式的第一步。但是,有一个“预览”模型,它跟踪鼠标位置并在预览窗格中的任何元素上显示样式属性值。这个“悬停预览”仅在未选择元素时激活,并通过这三个函数实现

function previewMouseOver(element) {
    // Setup for mouse click elsewhere.
    if (element.classList.contains('__preview')) {
        hoverElement = undefined;
    } else {
        hoverElement = element;
    }

    // Ignore the __preview box itself and, if we have a selected element,
    // don't update the property list during a hover.
    if (!element.classList.contains('__preview') && !selectedElement) {
        showProperties(element);
    }
    else {
        clearProperties();
    }
}

function showProperties(element) {
    let tagName = element.tagName;
    $('.__elementTagName').text(tagName);
    $('.__bothPropertiesContainer').removeClass('__hidden');
    $('.__propertyName').val($(element).attr('name'));
    $('.__propertyId').val($(element).attr('id'));
    showAllowableSections(tagName);
    populateStyleValues(element, tagName);
}

function clearProperties() {
    if (!selectedElement) {
        $('.__elementTagName').text('');
        $('.__bothPropertiesContainer').addClass('__hidden');
        hoverElement = undefined;
    }
}

支持函数 showAllowableSectionspopulateStyleValues 实现如下

function showAllowableSections(tagName) {
    let lcTagName = tagName.toLowerCase();
    let sectionNames = sections.map(s => s.name);
    let section = tagSections.filter(s => s.tag == lcTagName)[0];

    if (section) {
        let allowableSections = section.sections;

        sectionNames.forEach(sn => {
            let sectionName = '.__sectionName' + sn;
            let contentName = '.__content' + sn;
            if (allowableSections.includes(sn)) {
                $(sectionName).removeClass('__hidden');

                // Restore state when section can be displayed.
                if (sectionContentVisible[contentName]) {
                    $(contentName).removeClass('__hidden');
                } else {
                    $(contentName).addClass('__hidden');
                }
            } else {
                // always hide if excluded.
                $(sectionName).addClass('__hidden');
                $(contentName).addClass('__hidden');
            }
        });
    } else {
        clearProperties();
    }
}

function populateStyleValues(el, tagName) {
    let lcTagName = tagName.toLowerCase();
    let sectionNames = sections.map(s => s.name);
    let section = tagSections.filter(s => s.tag == lcTagName)[0];

    if (section) {
        let allowableSections = section.sections;

        allowableSections.forEach(s =>
        {
            if (sections.filter(section => section.name == s).length > 0) {
                let sectionStyles = sections.filter(section => section.name == s)[0].styles;

                sectionStyles.forEach(sectStyle =>
                {
                    let inputElement = '#__inputProperty' + sectStyle.style;

                    if (sectStyle.control == 'checkbox') {
                        let attr = sectStyle.style;
                        let checked = $(el).prop(attr);
                        $(inputElement + 'h').prop('checked', checked);
                        $(inputElement + 'v').prop('checked', checked);
                    } else {
                        let styleValue = $(el).css(sectStyle.style);
                        $(inputElement + 'h').val(styleValue);
                        $(inputElement + 'v').val(styleValue);
                    }
                });
            } else {
                console.log('Section ' + s + ' is not defined.');
            }
        });
    }
}

请注意,populateStyleValues 是唯一检查控件类型的案例,因为我们必须以不同于 input 元素值的方式处理 checkbox 状态的设置。

当用户单击一个元素时,由父级 div: 处理

$('.__preview').click(event => previewClick(event));

当前悬停的元素将被选中,如果没有元素被选中,则清除属性,预览行为恢复到“悬停预览”

function previewClick(event) {
    selectedElement = hoverElement;

    if (selectedElement) {
        showProperties(selectedElement);
    } else {
        clearProperties();
    }
}

样式编辑器事件

为样式部分的输入框元素连接了两个事件,keydownblur,适用于水平和垂直视图

$(document).on('keydown', inputElement + 'h', onInputKeyPress);
$(document).on('blur', inputElement + 'h', onUpdateElementStyle);

$(document).on('keydown', inputElement + 'v', onInputKeyPress);
$(document).on('blur', inputElement + 'v', onUpdateElementStyle);

以及每个视图的 checkbox 控件一个

$(document).on('click', inputElement + 'h', onCheckbox);
$(document).on('click', inputElement + 'v', onCheckbox);

keydown 事件和 blur 事件共享通用代码,但它们调用 updateElementStyle 方法的原因只是为了将事件函数与执行样式更新的逻辑分开。这样,您可以向事件处理程序添加特定功能,而不会影响底层行为。

function onInputKeyPress(event) {
    if (event.keyCode == 13) {
        updateElementStyle(event);
    }
}

function onUpdateElementStyle(event) {
    updateElementStyle(event);
}

function updateElementStyle(event) {
    let elName = '#' + event.target.id;
    let el = $(elName);
    let attr = $(el).attr('cssName');
    let val = el.val();
    $(selectedElement).css(attr, val);
    updateSource();
    updateOtherPropertyGrid(elName, val);
}

请注意,“其他”视图的属性面板也已更新

function updateOtherPropertyGrid(elName, val) {
    // We also need to update the complimentary input in the other properties section
    // Remove the trailing h or v.
    let hv = elName[elName.length - 1];
    let althv = hv == 'h' ? 'v' : 'h';
    let elx = elName.slice(0, -1) + althv;
    $(elx).val(val);
}

checkbox 事件类似

function onCheckbox(event) {
    let elName = '#' + event.currentTarget.id;
    let el = $(elName);
    let attr = $(el).attr('cssName');
    let checked = el.is(':checked');
    $(selectedElement).prop(attr, checked);
    updateSource();

    // like updateOtherPropertyGrid
    let hv = elName[elName.length - 1];
    let althv = hv == 'h' ? 'v' : 'h';
    let elx = elName.slice(0, -1) + althv;
    $(elx).prop('checked', checked);
}

请注意,在所有情况下,都会调用 updateSource 函数,该函数会更新编辑器中的文本

function updateSource() {
    let editor = $(currentEditor);
    let preview = $(currentPreview);
    let text = preview.html();
    editor.val(text);
}

编辑器键盘事件

在编辑 HTML 时,有一些我认为非常棒的功能。按键被检查是否为快捷键,该快捷键会插入或切换(添加或删除)标记(或 keymap 元数据中定义的任何内容)

function editorKeyPress(event, sourceEditor, targetPreview) {
    let key = String.fromCharCode(event.which);
    let editor = $(sourceEditor);
    let preview = $(targetPreview);
    let editorText = editor.val();

    if (ctrlKeyDown || altKeyDown) {
        let start = editor[0].selectionStart;
        let end = editor[0].selectionEnd;
        let s1 = editorText.substring(0, start);
        let s2 = editorText.substring(end);
        let between = editorText.substring(start, end);

        // TODO: Check that other metakeys aren't down
        // TODO: Handle combination of meta keys.
        // TODO: Maybe handle key combinations.
        if (altKeyDown) {
            map = keymap.filter(k => k.special == 'alt' && k.key == key);

            if (map.length == 1) {
                map = map[0];

                if (map.toggle) {
                    toggleTags(editor, preview, map.insert[0], map.insert[1],
                               editorText, s1, between, s2, start, end);
                } else {
                    insertTags(editor, preview, map.insert[0], map.insert[1],
                               s1, between, s2, end, map.eoi);
                }

                event.preventDefault();
            }
        }

        /*
        if (ctrlKeyDown) {
            switch(key) {
            }
        }
        */
    } else {
        clearSpuriousStyle();
        preview.html(editorText);
    }
}

插入和切换功能

function insertTags(editor, preview, t1, t2, s1, between, s2, end, eoiFlag) {
    let newText = s1 + t1 + between + t2 + s2;
    let newIdx = end + t1.length;
    $(editor).val(newText);

    if (eoiFlag) {
        editor[0].selectionStart = newIdx + t2.length;
        editor[0].selectionEnd = newIdx + t2.length;
    } else {
        // Set cursor between the tags and at the end of the selected (if any) text.
        editor[0].selectionStart = newIdx;
        editor[0].selectionEnd = newIdx;
    }

    preview.html(newText);
}

// Not the smartest toggling, as it doesn't search for the outermost tags but only
// if they are immediately adjacent to the selected text.
function toggleTags(editor, preview, t1, t2, editorText, s1, between, s2, start, end) {
    var newText = editorText;

    if (start - t1.length >= 0 &&
        (editorText.substring(start-t1.length, start) == t1) &&
        end + t1.length < editorText.length &&
        (editorText.substring(end, end + t2.length) == t2) ) {
        // Remove tags
        s1 = editorText.substring(0, start - t1.length);
        between = editorText.substring(start, end);
        s2 = editorText.substring(end + t2.length);
        newText = s1 + between + s2;
        $(editor).val(newText);

        // Preserve selected text.
        editor[0].selectionStart = start - t1.length;
        editor[0].selectionEnd = end - t1.length;
    } else {
    // Add tags.
        newText = s1 + t1 + between + t2 + s2;
        $(editor).val(newText);

        // Preserve selected text.
        editor[0].selectionStart = start + t1.length;
        editor[0].selectionEnd = end + t1.length;
    }

    preview.html(newText);
}

技巧:清除样式规则

那么,当按键正常处理时,调用 clearSpuriousStyle 的目的是什么?首先,让我描述一下问题。假设您从默认水平视图中的演示标记开始

接下来,您将 DIV 的背景颜色更改为 green

为什么它仍然是红色的???

原因是 document 出于某种原因有两个相同的编辑器样式表版本,索引 0 是编辑器本身的 CSS 规则

此行为仅在水平视图中发生,因为 CSS 位于索引 1 处,该索引被忽略。垂直视图正在使用索引 2 处的 CSS 规则,因此问题不会发生。因为两个视图共享相同的 CSS 规则,所以我们需要删除索引 2 处的规则,以便当您在编辑器中编辑标记时,索引 1 处的 CSS 规则会得到更新

// FM: We have to clear the CSS rules of the last style sheet so that the preview
// is responsive to changes in the<style type="text/css">section of the editor.</style>
function clearSpuriousStyle() {
    let i = document.styleSheets.length;
    let ss = document.styleSheets[i - 1];

    if (i > 1) {
        while (ss.rules.length > 0) {
            ss.deleteRule(0);
        }
    }
}

这是一个真正的权宜之计——它对每个按键都执行,而且可能存在 bug。

这就是 clearSpuriousStyle 所做的,也是为什么这被称为“FM”(自行解决)的原因。

技巧:制表符和编码键

keydown 事件中处理 Tab 键很容易

function editorKeyDown(event, editor, preview) {
    altKeyDown = event.altKey;
    ctrlKeyDown = event.ctrlKey;

    if (event.keyCode == 9 && !event.shiftKey) {
        insertTab(editor, preview);
        event.preventDefault();
    } else if (event.keyCode == 9 && event.shiftKey) {
        removeTab(editor, preview);
        event.preventDefault();
    }

    insideCodeBlockHandling(event, editor, preview);
}

但是,我添加了一个功能,可以自动将原本会被视为标记的字符转换为其编码的 HTML 等效字符

function insideCodeBlockHandling(event, editor, preview) {
    // Map to encoded HTML if inside a code or pre block.
    let start = $(editor)[0].selectionStart;
    let editorText = $(editor).val();
    let s1 = editorText.substring(0, start);

    if (inTag(s1, 'pre') || inTag(s1, 'code')) {
        let char = mapKeyPressToActualCharacter(event.originalEvent.shiftKey, event.which);
        let echar = $(editor).html(char).html();

        if (char != echar) {
            let end = $(editor)[0].selectionEnd;
            let s2 = editorText.substring(end);
            let newText = s1 + echar + s2;
            $(editor).val(newText);
            let curPos = s1.length + echar.length;
            $(editor)[0].selectionStart = curPos;
            $(editor)[0].selectionEnd = curPos;
            $(preview).html(newText);
            event.preventDefault();
        }
    }
}

mapKeyPressToActualCharacter 函数来自这个 SO 帖子:https://stackoverflow.com/a/22975675/2276361

使用此代码,如果我在 precode 块之外键入,字符如 <、> 和 & 会显示为实际字符,而在 codepre 块内键入时,它们会被编码为 &lt;、&gt; 和 &。这为编辑器增加了一些(但不完美)智能,这样我在输入时就不必考虑编码了。通常,作为一个纯代码编辑器,这些字符总是会被 HTML 编码,但由于编辑器支持实际的 HTML 元素,所以我将其实现为一种处理使用编辑器撰写文章和使用编辑器玩转 HTML 布局这两种场景的方法。

粘贴时也使用了相同的“技巧”,这在粘贴代码片段时特别有用

function onPaste(event, editor, preview) {
    let ret = true;
    let clippy = event.originalEvent.clipboardData;
    let textItems = Object.filter(clippy.items,
                 item => item.kind == 'string' && item.type == 'text/plain');

    if (Object.getOwnPropertyNames(textItems).length == 1) {
        let start = $(editor)[0].selectionStart;
        let end = $(editor)[0].selectionEnd;
        let editorText = $(editor).val();
        let s1 = editorText.substring(0, start);
        let s2 = editorText.substring(end);
        let text = clippy.getData('text/plain');

        // Now we have to check if we're inside a <code> or <pre> block, as these
        // need to be encoded so that < becomes < etc.  Otherwise we assume the user
        // is pasting a real HTML element.
        // Note that we don't check for closing </code> or </pre> tags to the right
        // of the current position, as the user may not have created these yet.

        if (inTag(s1, 'pre') || inTag(s1, 'code')) {
            text = $(editor).html(text).html();
        }

        let newText = s1 + text + s2;
        $(editor).val(newText);
        let curPos = s1.length + text.length;
        $(editor)[0].selectionStart = curPos;
        $(editor)[0].selectionEnd = curPos;
        $(preview).html(newText);
        ret = false;
    }

    return ret;
}

其中 inTag 定义如下

// Returns true if the text contains a final open tag but not the closing tag.
// Open tag can be of the form "<tag>" or "<tag " (note space)
function inTag(s, tag) {
    let tagOpen = '<' + tag + '>';
    let tagAltOpen = '<' + tag + ' ';
    let tagClose = '</' + tag + '>';
    let idxOpen = s.lastIndexOf(tagOpen);
    let idxAltOpen = s.lastIndexOf(tagAltOpen);
    let idxClose = s.lastIndexOf(tagClose);

    return idxOpen > idxClose || idxAltOpen > idxClose;
}

生成目录

我还希望将 TOC 生成内置到编辑器中,所以这是

// Will be fooled by putting <h> tags into quoted strings.
function generateTOC() {
    let toc = '<ul>';
    let text = $(currentEditor).val().toLowerCase();
    let originalText = $(currentEditor).val();
    let level = 1;
    // Next opening <h[n]> tag in the text.
    let idx = text.indexOf('<h');
    let n = 0;      // href tag.
    // index into original text, for getting the header without toLowerCase
    // and to insert the <a name> and </a> tags.
    let q = 0;

    while (idx != -1) {
        let cnum = text[idx + 2];
        let num = undefined;

        // If it's really of the form <h[n]...
        if (!isNaN(num = parseInt(cnum, 10))) {
            // indent or outdent to the new level.
            while (num > level) {
                toc = toc + '<ul>';
                ++level;
            }

            while (num < level) {
                toc = toc + '</ul>';
                --level;
            }

            idx += 4;
            q += idx;

            // Remove the <a name> and closing </a> if it already exists.
            if (originalText.substring(q, q + '<a name="'.length) == '<a name="') {
                // remove the <a name> and </a> from the original text...
                originalText = removeAName(originalText, q);
                // ... and the working text.
                text = removeAName(text, idx);
            }

            // Get the body of the header.
            let hdr = text.substring(idx);
            hdr = originalText.substring(q, q + hdr.indexOf('</'));
            toc = toc + '<li><a href="#' + n.toString() + '">' + hdr + '</a></li>';

            // Insert the <a name> and closing </a> around the header text.
            let s1 = originalText.substring(0, q);
            let s2 = originalText.substring(q + hdr.length);
            let aname = '<a name="' + n.toString() + '">' + hdr + '</a>';
            originalText = s1 + aname + s2;
            q += aname.length + '</hn>'.length;
            ++n;

            // Update the index to just past </h[n]>
            let hdrLen = hdr.length + '</hn>'.length;
            idx += hdrLen;
        } else {
            // Skip whatever the <h thing is we found.
            idx += 2;
            q += 2;
        }

        text = text.substring(idx);
        idx = text.indexOf('<h');
    }

    // Close off the ul's.
    while (level > 0) {
        toc = toc + '</ul>';
        --level;
    }

    // Update the preview with the <a name> tags.
    $(currentPreview).html(originalText);
    // Then set the #toc div.
    $("#toc").html(toc);
    // Then update the editor text.
    updateSource();
}

以及删除现有 <a name></a> 标签的辅助函数,这些标签是标题的一部分

function removeAName(text, idx) {
    s1 = text.substring(0, idx);
    anameIdx = idx + text.substring(idx).indexOf('">') + 2;
    s2 = text.substring(anameIdx);
    hdr = s2.substring(0, s2.indexOf('</a>'));
    s3 = s2.substring(hdr.length + '</a>'.length);
    text = s1 + hdr + s3;

    return text;
}

结论

这个编辑器仍然处于原型阶段,但是我已经使用这个编辑器写完了整篇文章,所以它实际上非常有用。而且正如引言中所提到的,您可以使用 Quick HTML Previewer 在 Visual Studio Code 中运行代码!

历史

  • 2019 年 9 月 15 日:初始版本
© . All rights reserved.