iOS小技能:富文本编辑器(上篇)

553 阅读4分钟

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

引言

  1. 富文本编辑器的应用场景: 编辑商品详情

预览:

  1. 设计思路: 编辑器基于WKWebview实现,Editor使用WKWebview加载一个本地editor.html文件,Editor使用evaluateJavaScript执行JS往本地html添加标签代码,编辑器最终输出富文本字符串(html代码)传输给服务器。
"remark":"<p>商品详情看看</p>\n<p style=\"text-align: right;\">jjjj<img src=\"http://bug.xxx.com:7000/zentao/file-read-6605.png\" alt=\"dddd\" width=\"750\" height=\"4052\" /></p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\">&nbsp;</p>\n<p style=\"text-align: right;\"><img src=\"http://bug.xxx.com:7000/zentao/file-read-6605.png\" alt=\"\" width=\"750\" height=\"4052\" /></p>"

  1. 使用IQKeyboardManager 键盘管理工具,布局采用Masonry,MVVM数据绑定。

  2. 界面设计:推荐把工具栏添加到键盘,或者放在富文本编辑器的顶部

在这里插入图片描述

I 前置知识

  1. 获取当前页面的html : https://blog.csdn.net/z929118967/article/details/77879309

  2. WKWebView替代UIWebView: https://blog.csdn.net/z929118967/article/details/115673455

  3. iOS加载本地HTML、pdf、doc、excel文件 & HTML字符串与富文本互转 https://blog.csdn.net/z929118967/article/details/90579369

  4. IQKeyboardManager 键盘管理工具(个性化设置): https://blog.csdn.net/z929118967/article/details/103766552

  5. iOS小技能:MVVM数据绑定的实现方式 blog.csdn.net/z929118967/…

  6. base64字符串与图片的互转

1.1 加载本地html

本地html

<!DOCTYPE html>
<html>
<head>
<title>RichTextEditor</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
<meta name="referrer" content="no-referrer">


<!-- jQuery Source For RichTextEditor -->
<script>
<!-- jQuery -->
</script>
        
<script>
<!-- jsbeautifier -->
</script>

<script>
<!--editor-->
</script>

使用[_webView loadHTMLString:html baseURL:baseURL]; 进行代码加载

    NSString *filePath = [[NSBundle mainBundle] pathForResource:@"editor" ofType:@"html"];
    NSData *htmlData = [NSData dataWithContentsOfFile:filePath];
    NSString *htmlString = [[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding];

    NSString *basePath = [[NSBundle mainBundle] bundlePath];
    NSURL *baseURL = [NSURL fileURLWithPath:basePath];
    [self.editorView loadHTMLString:htmlString baseURL:baseURL];

iOS加载本地HTML、pdf、doc、excel文件 & HTML字符串与富文本互转 https://blog.csdn.net/z929118967/article/details/90579369

往html追加字符串

    NSString *source = [[NSBundle mainBundle] pathForResource:@"RichTextEditor" ofType:@"js"];
    NSString *jsString = [[NSString alloc] initWithData:[NSData dataWithContentsOfFile:source] encoding:NSUTF8StringEncoding];
    htmlString = [htmlString stringByReplacingOccurrencesOfString:@"<!--editor-->" withString:jsString];
    

1.2 OC执行JS

文字的加粗、下划线、斜体等样式通过- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler; 执行js。

加粗

@implementation WKWebView (JSTool)

#pragma mark - 加粗
- (void)setBold {
    NSString *trigger = @"_editor.setBold();";
    [self evaluateJavaScript:trigger completionHandler:nil];
}

	<div id="_editor_content" class="_editor_content" contenteditable="true" placeholder="请输入文章正文"></div>

js

var _editor = {};


_editor.setBold = function() {
    document.execCommand('bold', false, null);
    _editor.enabledEditingItems();
}

_editor.getText = function() {
    return $('#_editor_content').text();
}


_editor.getHTML = function() {
    
    // Get the contents
    var h = document.getElementById("_editor_content").innerHTML;
    
    return h;
}

_editor.enabledEditingItems = function(e) {
    
    var items = [];
    
    var fontSizeblock = document.queryCommandValue('fontSize');
    if (fontSizeblock.length > 0) {
        items.push(fontSizeblock);
    }
    
    if (_editor.isCommandEnabled('bold')) {
        items.push('bold');
    }
    if (_editor.isCommandEnabled('italic')) {
        items.push('italic');
    }
    if (_editor.isCommandEnabled('subscript')) {
        items.push('subscript');
    }
    if (_editor.isCommandEnabled('superscript')) {
        items.push('superscript');
    }
    if (_editor.isCommandEnabled('strikeThrough')) {
        items.push('strikeThrough');
    }
    if (_editor.isCommandEnabled('underline')) {
        items.push('underline');
    }
    if (_editor.isCommandEnabled('insertOrderedList')) {
        items.push('orderedList');
    }
    if (_editor.isCommandEnabled('insertUnorderedList')) {
        items.push('unorderedList');
    }
    if (_editor.isCommandEnabled('justifyCenter')) {
        items.push('justifyCenter');
    }
    if (_editor.isCommandEnabled('justifyFull')) {
        items.push('justifyFull');
    }
    if (_editor.isCommandEnabled('justifyLeft')) {
        items.push('justifyLeft');
    }
    if (_editor.isCommandEnabled('justifyRight')) {
        items.push('justifyRight');
    }
    if (_editor.isCommandEnabled('insertHorizontalRule')) {
        items.push('horizontalRule');
    }
    var formatBlock = document.queryCommandValue('formatBlock');
    if (formatBlock.length > 0) {
        items.push(formatBlock);
    }
    // Images
    //    $('img').bind('touchstart', function(e) {
    //                  $('img').removeClass('zs_active');
    //                  $(this).addClass('zs_active');
    //                  });
    
    // Use jQuery to figure out those that are not supported
    if (typeof(e) != "undefined") {
        
        // The target element
        var s = _editor.getSelectedNode();
        var t = $(s);
        var nodeName = e.target.nodeName.toLowerCase();
        
        // Background Color
        var bgColor = t.css('backgroundColor');
        if (bgColor.length != 0 && bgColor != 'rgba(0, 0, 0, 0)' && bgColor != 'rgb(0, 0, 0)' && bgColor != 'transparent') {
            items.push('backgroundColor');
        }
        // Text Color
        var textColor = t.css('color');
        if (textColor.length != 0 && textColor != 'rgba(0, 0, 0, 0)' && textColor != 'rgb(0, 0, 0)' && textColor != 'transparent') {
            items.push('textColor');
        }
        
        //Fonts
        var font = t.css('font-family');
        if (font.length != 0 && font != 'Arial, Helvetica, sans-serif') {
            items.push('fonts');
        }
        
        // Link
        if (nodeName == 'a') {
            _editor.currentEditingLink = t;
            var title = t.attr('title');
            items.push('link:'+t.attr('href'));
            if (t.attr('title') !== undefined) {
                items.push('link-title:'+t.attr('title'));
            }
            
        } else {
            _editor.currentEditingLink = null;
        }
        // Blockquote
        if (nodeName == 'blockquote') {
            items.push('indent');
        }
        // Image
        if (nodeName == 'img') {
            _editor.currentEditingImage = t;
            items.push('image:'+t.attr('src'));
            if (t.attr('alt') !== undefined) {
                items.push('image-alt:'+t.attr('alt'));
            }
            
        } else {
            _editor.currentEditingImage = null;
        }
        
    }
    
    
    
    var arttitle = document.getElementById('vj_article_title');
    var artAbsTitle = document.getElementById('vj_article_abstract');
    var artContent = document.getElementById('_editor_content');
    
    if (arttitle == document.activeElement) {
        window.location = "state-title://"+items.join(',');
    }
    
    if (artAbsTitle == document.activeElement) {
        window.location = "state-abstract-title://"+items.join(',');
    }
    
    if (artContent == document.activeElement) {
        window.location = "callback://0/"+items.join(',');
    }
    
}

1.3 JS调用iOS

JS侧代码:

window.webkit.messageHandlers.openImage.postMessage($(this).attr("src"));
// 给openImage 传递SRC参数
// 监听点击事件调研OC方法
    <script>
        var div = document.getElementById('_column');
        div.addEventListener('click', test);
        
        function test(e) {

           window.webkit.messageHandlers.column.postMessage({
                  "body": "buttonActionMessage"
              });

        }
    </script>

OC侧代码使用configuration对象初始化webView,并遵守WKScriptMessageHandler协议监听JS的调用

NSString * const k_openImage4js = @"openImage";

//使用configuration对象初始化webView
- (WKWebView *)webView {
    if (_webView) return _webView;



WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];

    [_webView.configuration.userContentController addScriptMessageHandler:self name:k_openImage4js];

//! 使用configuration对象初始化webView
_webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
    return _webView;

}


#pragma mark - ********  处理与JS的桥接
/**
接收参数
*/
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    
    if ([message.name caseInsensitiveCompare:k_openImage4js] == NSOrderedSame) {
        
        NSLog(@"message.name:%@,message.body:%@",message.name,message.body);
        
        [self ImageZoomScaleWithUrl:message.body];
        
        
    }
    
    
    
}


II iOS侧代码

2.1 web页面获取焦点时弹出键盘

  1. UIWebView 中 keyboardDisplayRequiresUserAction 设置为 NO

A Boolean value indicating whether web content can programmatically display the keyboard.

  1. WKWebView中需要针对不同操作系统进行相关方法的重写。
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [self focusTextEditor];
        });

- (void)focusTextEditor {
    
    //TODO: Is this behavior correct? Is it the right replacement?
//    self.editorView.keyboardDisplayRequiresUserAction = NO;
    [ZSSRichTextEditor allowDisplayingKeyboardWithoutUserAction];
    
    NSString *js = [NSString stringWithFormat:@"zss_editor.focusEditor();"];
    [self.editorView evaluateJavaScript:js completionHandler:^(NSString *result, NSError *error) {
     
    }];

}


#pragma mark - Convenience replacement for keyboardDisplayRequiresUserAction in WKWebview

+ (void)allowDisplayingKeyboardWithoutUserAction {
    Class class = NSClassFromString(@"WKContentView");
    NSOperatingSystemVersion iOS_11_3_0 = (NSOperatingSystemVersion){11, 3, 0};
    NSOperatingSystemVersion iOS_12_2_0 = (NSOperatingSystemVersion){12, 2, 0};
    NSOperatingSystemVersion iOS_13_0_0 = (NSOperatingSystemVersion){13, 0, 0};
    if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: iOS_13_0_0]) {
        SEL selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:");
        Method method = class_getInstanceMethod(class, selector);
        IMP original = method_getImplementation(method);
        IMP override = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, BOOL arg3, id arg4) {
        ((void (*)(id, SEL, void*, BOOL, BOOL, BOOL, id))original)(me, selector, arg0, TRUE, arg2, arg3, arg4);
        });
        method_setImplementation(method, override);
    }
   else if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: iOS_12_2_0]) {
        SEL selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:changingActivityState:userObject:");
        Method method = class_getInstanceMethod(class, selector);
        IMP original = method_getImplementation(method);
        IMP override = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, BOOL arg3, id arg4) {
        ((void (*)(id, SEL, void*, BOOL, BOOL, BOOL, id))original)(me, selector, arg0, TRUE, arg2, arg3, arg4);
        });
        method_setImplementation(method, override);
    }
    else if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: iOS_11_3_0]) {
        SEL selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:changingActivityState:userObject:");
        Method method = class_getInstanceMethod(class, selector);
        IMP original = method_getImplementation(method);
        IMP override = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, BOOL arg3, id arg4) {
            ((void (*)(id, SEL, void*, BOOL, BOOL, BOOL, id))original)(me, selector, arg0, TRUE, arg2, arg3, arg4);
        });
        method_setImplementation(method, override);
    } else {
        SEL selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:userObject:");
        Method method = class_getInstanceMethod(class, selector);
        IMP original = method_getImplementation(method);
        IMP override = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, id arg3) {
            ((void (*)(id, SEL, void*, BOOL, BOOL, id))original)(me, selector, arg0, TRUE, arg2, arg3);
        });
        method_setImplementation(method, override);
    }
}


2.2 去掉键盘自带的工具条

原生中隐藏AccessoryView

self.textView.inputView = nil

将UIWebBrowserViewMinusAccessoryView的inputAccessoryView替换为空

UIWebBrowserViewMinusAccessoryView->WKScrollView->WKWebView

去掉WKWebView键盘自带的工具条:修改browserView的inputAccessoryView属性getter方法返回nil

@interface WKWebView (HackishAccessoryHiding)
@property (nonatomic, assign) BOOL hidesInputAccessoryView;
@end

@implementation WKWebView (HackishAccessoryHiding)

static const char * const hackishFixClassName = "WKWebBrowserViewMinusAccessoryView";
static Class hackishFixClass = Nil;

- (void) setHidesInputAccessoryView:(BOOL)value {
    UIView *browserView = [self hackishlyFoundBrowserView];//查找browserView
    if (browserView == nil) {
        return;
    }
    // 将inputAccessoryView的实现替换为nil
    [self ensureHackishSubclassExistsOfBrowserViewClass:[browserView class]];
    
    if (value) {
        object_setClass(browserView, hackishFixClass);
    }
    else {
        Class normalClass = objc_getClass("WKWebBrowserView");
        object_setClass(browserView, normalClass);
    }
    [browserView reloadInputViews];
}

- (void)ensureHackishSubclassExistsOfBrowserViewClass:(Class)browserViewClass {
    if (!hackishFixClass) {
        Class newClass = objc_allocateClassPair(browserViewClass, hackishFixClassName, 0);
        newClass = objc_allocateClassPair(browserViewClass, hackishFixClassName, 0);
        IMP nilImp = [self methodForSelector:@selector(methodReturningNil)];
        class_addMethod(newClass, @selector(inputAccessoryView), nilImp, "@@:");
        objc_registerClassPair(newClass);
        
        hackishFixClass = newClass;
    }
}
- (id)methodReturningNil {
    return nil;
}

2.3 判断键盘的弹出与关闭状态

-(void)addNotification{
//    [[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(keyBoardWillChangeFrame:) name:UIKeyboardWillChangeFrameNotification object:nil];
    
    //isVisable

    
     [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidShow)name:UIKeyboardDidShowNotification object:nil];
        
    

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidHide)name:UIKeyboardWillHideNotification object:nil];

    
}
- (void)dealloc{
    [[NSNotificationCenter defaultCenter]removeObserver:self];;

    
}

-(void)keyboardDidHide{
    
    self.isVisable = NO;
    
}



-(void)keyboardDidShow{
    
    self.isVisable = YES;

}

2.4 处理自定义键盘工具条的显示与隐藏

//处理键盘工具条显示与隐藏
- (void)handleEvent:(NSString *)urlString{
    
    if ([urlString hasPrefix:@"state-title://"] || [urlString hasPrefix:@"state-abstract-title://"]) {
        self.fontBar.hidden = YES;
        self.toolBarView.hidden = YES;
    }else if([urlString rangeOfString:@"callback://0/"].location != NSNotFound){
        self.fontBar.hidden = NO;
        self.toolBarView.hidden = NO;
        //更新 toolbar
        NSString *className = [urlString stringByReplacingOccurrencesOfString:@"callback://0/" withString:@""];
        [self.fontBar updateFontBarWithButtonName:className];
    }
    
}

2.5 监听alertController的textField的内容

监听alertController的textField的内容,只有文本长度大于0,才可以点击完成按钮

    UIAlertAction *doneAction = [UIAlertAction actionWithTitle:@"完成" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
        
        
        UITextField *linkURL = [alertController.textFields objectAtIndex:0];
        UITextField *title = [alertController.textFields objectAtIndex:1];
        
        
        
        if (!self.viewModel.model4editor.isVisable) {
            

            
            [self.viewModel.model4editor.editorView focusTextEditor];
        }
        
        [self.viewModel.model4editor.editorView prepareInsertImage];
        [self.viewModel.model4editor.editorView insertImage:linkURL.text alt:title.text];
        
        
    }];
    
    doneAction.enabled = NO;

    
       [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) {
           textField.placeholder = @"URL (必填)";
           textField.rightViewMode = UITextFieldViewModeAlways;
           textField.clearButtonMode = UITextFieldViewModeAlways;
           
           
           // 监听textField的内容,只有文本长度大于0,才可以点击完成按钮
//           [textField addTarget:self action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged];
           
//           textField.delegate = weakSelf;
           
           [[textField rac_signalForControlEvents:(UIControlEventEditingChanged)] subscribeNext:^(__kindof UITextField * _Nullable x) {
               
                   if (x.text.length < 1) {//判断是否符合URL
                       doneAction.enabled = NO;
               
                   }else{
                       doneAction.enabled = YES;
                   }
               
               
               
           }];
           
           
        
       }];

2.6 插入连接

如果用户没有输入title,则title默认为URL

#pragma mark - 插入连接
- (void)insertLink:(NSString *)url title:(NSString *)title {
    
    
    if([NSStringQCTtoll isBlankString:title]){
        title =url;
        
    }
    
    
    NSString *trigger = [NSString stringWithFormat:@"zss_editor.insertLink(\"%@\", \"%@\");", url, title];
    [self evaluateJavaScript:trigger completionHandler:nil];
}

III JS侧代码

基于ZSSRichTextEditor实现

3.1 获得焦点

zss_editor.focusEditor = function() {
    
    var editor = $('#zss_editor_content');
    var range = document.createRange();
    range.selectNodeContents(editor.get(0));
    range.collapse(false);
    var selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    editor.focus();
}

3.2 监听网页上选定文本的变化

    $(document).on('selectionchange',function(e){
                   zss_editor.calculateEditorHeightWithCaretPosition();
                   zss_editor.setScrollPosition();
                   zss_editor.enabledEditingItems(e);
                   });
    
//输入文字时,插入符号位置计算
zss_editor.calculateEditorHeightWithCaretPosition = function() {
    
    var padding = 50;
    var c = zss_editor.getCaretYPosition();
    
    var editor = $('#zss_editor_content');
    
    var offsetY = window.document.body.scrollTop;
    var height = zss_editor.contentHeight;
    
    var newPos = window.pageYOffset;
    
    if (c < offsetY) {
        newPos = c;
    } else if (c > (offsetY + height - padding)) {
        newPos = c - height + padding - 18;
    }
    
    window.scrollTo(0, newPos);
}

3.3 插入连接

zss_editor.insertLink = function(url, title) {
    
    zss_editor.restorerange();
    var sel = document.getSelection();
    console.log(sel);
    if (sel.toString().length != 0) {
        if (sel.rangeCount) {
            
            var el = document.createElement("a");
            el.setAttribute("href", url);
            el.setAttribute("title", title);
            
            var range = sel.getRangeAt(0).cloneRange();
            range.surroundContents(el);
            sel.removeAllRanges();
            sel.addRange(range);
        }
    }
    else
    {
        document.execCommand("insertHTML",false,"<a href='"+url+"'>"+title+"</a>");
    }
    
    zss_editor.enabledEditingItems();
}


3.4 插入url图片

//先创建一个<span></span>标签
//延迟1s等待动态增加的标签<span>加入到DOM中,再向其中新增图片
//为什么不直接创建<img> 标签并指定src呢? 因为图片显示不出来。
zss_editor.priInsertImage = function(){
    zss_editor.restorerange();
    var html = '<span id="imageSpan"></span>';
    zss_editor.insertHTML(html);
    zss_editor.enabledEditingItems();
}

//插入url图片
zss_editor.insertImage = function(url, alt) {
    var img = document.createElement('img');//创建一个标签
    img.setAttribute('src',url);//给标签定义src链接
    img.setAttribute('alt',alt);//给标签定义alt
    document.getElementById('imageSpan').appendChild(img);//放到指定的id里
    
    zss_editor.deletInsertImageSpan();//删除插入url图片时创建的<span></span>标签
}

//删除插入url图片时创建的<span></span>标签
zss_editor.deletInsertImageSpan = function(){
    var html = $('#imageSpan').html();
    $('#imageSpan').before(html);
    $('#imageSpan').remove();
}

IV demo

blog.csdn.net/z929118967/…

see also

富文本编辑器:基于WKWebview实现,Editor使用WKWebview加载一个本地editor.html文件https://download.csdn.net/download/u011018979/85675638

github.com/zhangkn/KNR…

editorView4WKWebView :https://github.com/nnhubbard/ZSSRichTextEditor

  s.source       = { :git => "https://github.com/nnhubbard/ZSSRichTextEditor.git", :tag => "0.5.2.1" }

  s.source_files  = "**/*.{h,m}"
  s.exclude_files = "**/ZSSDemo*.{h,m}", "**/ZSSAppDelegate*.{h,m}", "**/main.m"

  s.resources = "**/ZSS*.png", "**/ZSSRichTextEditor.js", "**/editor.html", "**/jQuery.js", "**/JSBeautifier.js"

  s.frameworks = "CoreGraphics", "CoreText"

原生 iOS-Rich-Text-Editor