对兼容各操作系统的Anki选择题模板的更新——提供更方便的笔记修改功能

经过一段时间的使用和更新,此模板基本可以定稿了。将此文完全重写。

20240808完善最后一个小功能:选择文字清除格式后,很大概率会将所选择的文字修改为另外的格式,因此,点击清除格式按钮后最好能保持原来的选区。由于range.createContextualFragment()函数对于不包含HTML元素标签的文本不会做出任何修改,因此,选择最里层的某个标签里的部分文字后点击“清除格式”必须手工在选区前后分别插入所在元素的结束标签和开始标签,但是这样就破坏了原有的文档结构,导致range破坏,选区丢失。为了解决这个问题,在手工插入所在元素的结束标签和开始标签时再多插入一对自定义的标签(代码中取名tmpele),这样,在完成编辑字段的HTML替代后可以轻松找到自定义的结点,再使用range.selectNode即重新选择了原有选区。至于插入的自定义标签,它会在clearFormat函数中被清除,并不会污染笔记。

现在还有一个问题需要解决:复习界面点击编辑按钮进入编辑界面编辑笔记后,回到复习界面,成绩统计的总做题数量、单选题或多选题数量、得分等信息会发生变化。当然,这并不影响模板的使用,不过成绩统计会出现误差。这应该要通过修改插件给笔记提供更多可以区别是复习界面还是编辑界面的信息来解决,暂时还没有找到途径。

解决复习界面点击编辑按钮进入编辑界面编辑笔记后,回到复习界面时成绩统计信息出错的问题:这个稍微要增加点复杂度了主要是插件改动比较大。

1)在插件的__init__.py文件同一目录下创建reload.py,内容如下:

class Reload:
	isEditor = 'no'
	
reload = Reload()

上面这个类主要是提供一个保存是否进入过编辑界面编辑笔记的对象。直接在__init__.py文件中定义变量来保存这个信息在重新载入笔记时变量值被重新初始化了,所以只能用对象来保存。

2)在__init__.py文件中导入这个对象,为了清楚表示添加代码的位置,以编辑器截图表示(行号仅供参考,主要根据上下文确定添加位置,因为我在插件文件中添加了一些用不到的测试代码,行号与原始插件文件的行号不完全相同了。方框中的是添加的代码):

3)继续修改__init__.py文件,将是否进入编辑界面编辑笔记的信息传递给webview:

4)继续修改__init__.py文件,处理是否进入编辑界面编辑笔记的信息的修改。在on_js_message(handled, url, context)函数中添加:

5)继续修改__init__.py文件,给webview修改reload.isEditor提供接口:

插件端的修改完成了。在笔记模板上的修改比较简单,仅需将原来的背面内容模板中的

calcScore();

替换为:

var isEditor = $(contenteditable).data('iseditor');
//仅在不是从编辑界面回到复习界面时计算成绩,如果是从编辑界面回到复习界面,重置插件中的标志值
if(isEditor === 'no'){
	calcScore();
} else {		
	pycmd('ankisave!isEditor#');
}

当然,背面模板内容也可以直接复制下面的背面模板内容代码。

如果觉得以上1)~5)对插件的修改太复杂,可以用下面插件文件修改较少的方案(这个方案应该更好,为了保留一种思路,就不删除上面的方案了)。这个方案插件文件只需象上述第5)项修改那样添加一个setIsEditor钩子处理函数(也许改名为setCalc更贴切),并勾住webview_will_set_content,无需做第1)和第3)、4)步,第2)步只需引入Editor类,第5)步参考如下代码修改:

def setCalc(web_content, context):
    """进入编辑界面修改了笔记,调用Javascript将成绩计算标志设置为false"""
    if isinstance(context, Editor):
        mw.reviewer.web.eval("""
        	Persistence.setItem(`${deck}.calc`,false);
        """)
        return

gui_hooks.webview_will_set_content.append(setCalc)

在Anki卡片中,正面内容模板里增加初始化一个计数器,背面内容模板对calcScore函数调用逻辑重新做以下修改,修改后的代码均在下面更新了。插件文件的修改也在下面增添了一个截图。

1、模板字段:

2、安装Anki插件Edit Field During Review(安装ID:1020366288,以下简称插件),此插件支持在复习界面(Review)修改笔记(此模板中一条笔记就是一道选择题)。为了扩展该插件以充分发挥此模板的功能,需参照以下截图中行号所表示的位置及标记内容修改插件源代码(需要说明的是,如果没有在复习界面修改笔记的需求,也可以不安装和修改这个插件,不影响整个选择题模板的使用):

上面的修改使插件可以向笔记页面中使用了edit过滤器的字段传递牌组名。

上面的修改使插件支持在笔记页面中使用了edit过滤器的字段中插入HTML代码修改笔记,例如可以在其中用<table>标签插入表格,而无需进入编辑界面。在未做此项修改前,该插件仅支持在使用了edit过滤器的字段中添加和删除文本。

上面的修改配合笔记卡片正面内容模板为用户改变是否启用随机重排选项后立即更新笔记提供支持。

上面的修改使从编辑界面修改笔记后返回复习界面时不会计算成绩导致成绩统计出现错误。

3、下载Anki的跨平台持久化存储模块,并保存到%APPDATA%/anki2/anki登录用户名/collection.media文件夹中,下载地址:GitHub - SimonLammer的Anki持久化模块,文件名修改为“_ankiPersistence.js”,注意以下划线开头。

4、下载jquery的高亮标记模块,下载地址:jquery高亮标记模块,可选择下载jquery.mark.es6.min.js,然后保存到%APPDATA%/anki2/anki登录用户名/collection.media文件夹中,文件名修改为“_jquery.mark.es6.js”,注意以下划线开头。

5、模板的运作模式:

1)选项可以选择用拉丁字母编号还是罗马数字编号。此模板会随机重排笔记中的选项,如果笔记中本来使用了拉丁字母编号,并且解析字段中的内容提到了选项A如何如何,那么继续在笔记页面使用拉丁字母为选项编号就会造成混乱,因此提供这个选项。模板可根据笔记中答案的长度自行判断笔记为单选题还是多选题,单选题只能选择一个选项,因鼠标误击选择选项后再次点击即可取消该选项的选择。

小更新:为了满足不随机重排选项顺序的需求,将是否随机重排选项交由用户选择:

2)利用跨平台持久化技术,统计出一次练习的累计成绩信息,而不是只计算当前题目的成绩,并以不同的样式直观显示正确答案及回答情况。

3)提供复习界面修改笔记字段的功能。除使用键盘增加删除可编辑字段的文本及使用HTML代码插入带格式的内容及图片、表格和多媒体内容外,还提供了几个快速样式修改按钮,可以更方便地修改笔记的字段。

4)对动态调整的选项和正确答案,可以通过点击按钮将其显示后再进行修改。

5)如果解析内容较长,可以在解析区快速搜索到感兴趣的内容并使其高亮,但如不希望笔记重新载入时显示高亮标记(即高亮样式不被笔记保存),搜索完后应清空搜索框或者选择高亮内容点击清除格式。

6)如一次练习时做了不同牌组的练习题,各牌组的成绩独立统计,互不影响。

以下是此模板的完整代码,其中有较详尽的注释。

正面内容模板:

<!--正面内容模板-->
<!--导入持久化模块,注意文件名与保存到%APPDATA%/anki2/anki登录用户名/collection.media文件夹中的文件名一致-->
<script src='_ankiPersistence.js'></script>

<div class='tooltip'>
<span class='tooltiptext' style='width:150%;'>选择此选项,选择题选项的编号为罗马数字,否则为拉丁字母。</span>
	<input type='checkbox' id='roman' onchange='changeRoman(this)' class='tooltip'>	
		使用罗马数字为选项编号	
	</input>
</div>
<div class='tooltip'>
<span class='tooltiptext' style='width:150%;'>选择此选项,选项顺序将会随机重排,否则选项顺序总是与初始输入顺序相同。</span>
	<input type='checkbox' checked id='rand_options' onchange='changeRandOptions(this)' class='tooltip'>	
		选项顺序随机重排	
	</input>
</div>
<div class='pink' id='question'>{{edit:问题}}<span class='imp'>【所属章节:{{章节}}】<span></div>
<ol class='options' id='optionList'></ol>
<div id='options' style='display:none'>{{选项}}</div>
<div id='answer' style='display:none'>{{text:答案}}</div>


<script>
	var contenteditable = $('[contenteditable=true]')[0];
	var deck = $(contenteditable).data('deck');//当前牌组名,插件设置
	var myinfo = Persistence.getItem(`${deck}.myinfo`);
	if (myinfo === null) {
		myinfo = {
			single: 0, //本次已做全部练习题中单选题数量
			singleCorrect: 0, //本次已做全部练习题中单选题正确数量
			multi: 0, //本次已做全部练习题中多选题数量
			multiCorrect: 0, //本次已做全部练习题中多选题完全正确数量
			partCorrect: 0, //本次已做全部练习题中多选部分正确数量
			multiScore: 0, //本次已做全部练习题中多选题得分
			score: 0, //当前所作练习题得分
			sum: 0, //本次已做全部练习题累计得分
			total: 0, //本次已做练习总数量
			totalScore: 0, //本次已做练习满分
			newOrderOps: [], //当前所作练习题打乱顺序后的选项
			newOrderAnswer: '', //当前所作练习题打乱选项顺序后新的正确答案编号
			choiced: '', //当前所作练习题选中的选项
			ifright: '', //当前所作练习题选中的选项是否正确
		};
	}
	myinfo.choiced = '';
	myinfo.newOrderAnswer = '';
	myinfo.newOrderOps = [];

	var roman = Persistence.getItem(`${deck}.roman`);
	if(roman === null){
		Persistence.setItem(`${deck}.roman`,false);
	}else{		
		$('#roman').prop('checked',roman);
	}

	var randOptions = Persistence.getItem(`${deck}.randOptions`);
	if(randOptions === null){
		randOptions = true;
		Persistence.setItem(`${deck}.randOptions`, true);
	}else{		
		$('#rand_options').prop('checked', randOptions);
	}
	var question = document.getElementById('question');

	//读入答案,去掉多余字符和空格
	var correctAnswer = document.getElementById('answer').innerHTML
		.toUpperCase().replace(/[^A-Z]+/g, "");
	if (correctAnswer.length > 1) { //正确答案大于一个为多选题
		question.innerHTML = `<span class='imp'>【多选题】</span>${question.innerHTML}`;
	} else { //单选题 
		question.innerHTML = `<span class='imp'>【单选题】</span>${question.innerHTML}`;
	}

	var optionsEle = document.getElementById('options'),
		optionList = document.getElementById('optionList');
	var charCodeBasic = 65;	//'A'.charCodeAt(0)=65;
	/* 如果选项不是大写拉丁字母编号,改用大写罗马数字编号,将正确答案改成罗马字母*/
	if(roman){
		charCodeBasic = 8544;//大写罗马数字'Ⅰ'的unicode编码;
		correctAnswer = correctAnswer.replace(/(.)/g,(c='$1')=>{
			return String.fromCharCode(c.charCodeAt(0)+8479);	//8544-69
		});
		$('.options').css('listStyle','upper-roman');
	}else
		$('.options').css('listStyle','upper-latin');

	var indexs = [];
	//处理原始顺序的选项,将div标签和br标签以及多余的换行替换掉
	var options = optionsEle.innerHTML;
	options = options.replace(/<\/?div>/g, '\n');
	options = options.replace(/\n+/g, '\n');
	options = options.replace(/<br.*?>/g, '\n');
	options = options.replace(/^\n/, "");
	options = options.replace(/\n$/, "");
	//以换行符分隔选项为数组
	options = options.split('\n');

	if(randOptions){//重排选项顺序
		for (let i = 0; i < options.length; i++) {
			let s= 0;
			//随机产生一个索引,如果产生的索引已处理过,继续产生下一个索引,没处理过就中断循环开始处理
			do {
				s = Math.random() * (options.length);
				s = Math.floor(s);
				if (indexs.join().indexOf(s.toString()) < 0) {
					indexs.push(s);
					myinfo.newOrderOps.push(options[s]);
					break;
				}
			} while (true);
		}
	}else{//不重排选项顺序,直接按当前顺序复制到新顺序选项中
		for (let i = 0; i < options.length; i++) {
			indexs.push(i);
			myinfo.newOrderOps.push(options[i]);
		}
	}

	if(randOptions){//选项顺序随机重排,则正确答案修改为新的编号
		for (let i = 0; i < options.length; i++) {
			//将正确答案的字母序号转换成打乱顺序后的字母编号,并记录到myinfo.newOrderAnswer中
			if (correctAnswer.indexOf(String.fromCharCode(charCodeBasic + indexs[i])) >= 0) {
				myinfo.newOrderAnswer += String.fromCharCode(charCodeBasic + i);
			}
		}
	}else{//否则新顺序的正确答案与原来相同
		myinfo.newOrderAnswer = correctAnswer;
	}


	for(let i=0; i<myinfo.newOrderOps.length; i++){
		//将随机产生的选项组合成li包着的input和label
		const list = document.createElement('li'),
			label = document.createElement('label'),
			input = document.createElement('input');
		label.innerHTML = myinfo.newOrderOps[i];
		//将选项的文本与选项编号的颜色区别,以免选项中含拉丁字母时难以区别
		list.className = 'blue';
		label.className = 'unchoiced';
		//根据答案字符长短判定应该用多选框还是单选框
		input.type = correctAnswer.length > 1 ? 'checkbox' : 'radio';
		input.value = indexs[i];
		input.name = 'opts';//将选项成组,以防单选题可选择多个选项
		input.id = 'opts_' + indexs[i];
		label.for = 'opts_' + indexs[i];
		list.addEventListener('click', clickOption);
		list.appendChild(input);
		list.appendChild(label);
		optionList.appendChild(list);
	}

	Persistence.setItem(`${deck}.myinfo`,myinfo);
	//在选项li标签所在区域点击时,实际触发事件的可能是li、label或者input组件,无论是那个组件,都定位到checkbox
	function clickOption(ev) {
		let checkbox = ev.target;

		let tagName = checkbox.tagName;
		if (tagName === 'LI') {
			checkbox = checkbox.children[0];
		} else if (tagName === 'LABEL') {
			checkbox = checkbox.parentNode.children[0];
		}
		checkbox.checked = true;
		let li = checkbox.parentNode;
		li.className = li.className === 'choiced'?'blue':'choiced';
		let label = checkbox.nextSibling;
		label.className = li.className === 'choiced'?'choiced':'unchoiced';


		let s = checkbox.value;
		//在打乱顺序后的索引数组中找到选项的新数字序号,再转换成对应的字母编号
		let ch = String.fromCharCode(charCodeBasic + indexs.join('').indexOf(s.toString()));
		if (myinfo.choiced.indexOf(ch) < 0) {
			if (correctAnswer.length > 1) {//多选题,在已选择项上加上一个新选项
				myinfo.choiced += ch;
			} else {//单选题,将已选择项变更为刚选的选项
				myinfo.choiced = ch;
			}
		} else { //点击已选中的选项则取消该选项的选中状态
			myinfo.choiced = myinfo.choiced.replace(ch, '');
			checkbox.checked = false;
		}
		
		//if (Persistence.isAvailable()) {
		Persistence.setItem(`${deck}.myinfo`,myinfo);
		//根据选项是否被选择赋予不同的显示样式
		for (let j = 0; j < optionList.children.length; j++) {
			ch = String.fromCharCode(charCodeBasic + j)
			if (myinfo.choiced.indexOf(ch) < 0) {
				optionList.children[j].className = 'blue';
				optionList.children[j].children[1].className= 'unchoiced';
			} else {
				optionList.children[j].className = 'choiced';
				optionList.children[j].children[1].className= 'choiced';
			}
		}
	}
	/*选项编号方式选择发生变化时,改变选项的listStyle样式,并将正确答案和已选择的选项
	* 也修改为新的编号方式所用的符号
	*/
	function changeRoman(ele){
		Persistence.setItem(`${deck}.roman`,ele.checked);
		if(ele.checked){
			$('.options').css('listStyle','upper-roman');
			myinfo.newOrderAnswer = myinfo.newOrderAnswer.replace(
				/(.)/g,(c='$1')=>{
					return String.fromCharCode(c.charCodeAt(0)+8479);
			});
			myinfo.choiced = myinfo.choiced.replace(
				/(.)/g,(c='$1')=>{
					return String.fromCharCode(c.charCodeAt(0)+8479);
			});
			charCodeBasic = 8544;
		}
		else {
			$('.options').css('listStyle','upper-latin');
			myinfo.newOrderAnswer = myinfo.newOrderAnswer.replace(
				/(.)/g,(c='$1')=>{
					return String.fromCharCode(c.charCodeAt(0)-8479);
			});
			myinfo.choiced = myinfo.choiced.replace(
				/(.)/g,(c='$1')=>{
					return String.fromCharCode(c.charCodeAt(0)-8479);
			});
			charCodeBasic = 65;
		}
		Persistence.setItem(`${deck}.myinfo`,myinfo);
	}

	function changeRandOptions(ele){
		Persistence.setItem(`${deck}.randOptions`,ele.checked);
		pycmd('ankisave!reload#');
	}
    //编辑界面返回复习界面时,不知为什么笔记会被载入2次,增加这个计数变量使两次载入都不计算成绩
	Persistence.setItem(`${deck}.times`,2);
</script>

背面内容模板:

<!--背面模板-->
<!--导入持久化库和高亮库,注意文件名与保存到%APPDATA%/anki2/anki登录用户名/collection.media文件夹中的相关文件一致-->
<script src='_ankiPersistence.js'></script>
<script src='_jquery.mark.es6.js'></script>

<div id='performance'>正确率:100%</div>
<hr />
<div class='pink'>{{edit:问题}}<span class='imp'>【所属章节:{{章节}}】</span></div>
<ol class='options' id='optionList'></ol>
<hr />
<div id='key' class='text'>上面的选项以<span class='green'>此种形式</span>显示的为被你选中的正确选项,以<span
		class='blue'>此种形式</span>显示的为未被你选中的正确选项,以<span class='wrong'>此种形式</span>
	显示的不是正确选项却被你选中了。本题结果如下:<br />
</div>

<hr>
<div class='extra'>
	<span class='pink'>【解析】</span><br>{{edit:解析}}
</div>
<div id='styleButtons'>
	<button id='blue_bold' onclick="onBtnsClick('b')" class='button tada'>蓝色加粗</button>
	<button id='red_bold' onclick="onBtnsClick('u')" class='button tada'>红色加粗</button>
	<button id='green_bold' onclick="onBtnsClick('i')" class='button tada'>绿色加粗</button>
	<button id='orange_bold' onclick="onBtnsClick('a')" class='button tada'>橙色加粗</button>
	<button id='sup' onclick="onBtnsClick('sup')" class='button tada'>上标</button>
	<button id='sub' onclick="onBtnsClick('sub')" class='button tada'>下标</button>
	<button id='clear_format' onclick="onBtnsClick('clear_format')" class='tooltip button tada'>清除格式
		<span class='tooltiptext'
			style='width:400%;'><i>慎用此按钮!</i>此按钮点击后除br、table、tr、td、img以及不带属性的div和p元素的标签被保留外,所有其它HTML标签都将被删除。</span>
	</button>
	<button id='editOptions' onclick="onBtnsClick('editOptions')" class='button tada'>
		编辑选项与答案
	</button>
	<button id='dis_html' onclick="onBtnsClick('dis_html')" class='tooltip button tada'>编辑HTML
		<span class='tooltiptext' style='width:400%;'>直接以HTML代码编辑字段建议先按此按钮切换为HTML,编辑完成后再次点击此按钮完成保存。</span>
	</button>
	<div class='tooltip' style='display:inline-block;width:10%;'>
		<input id='search' type='text' placeholder='请输入搜索内容...'>
		<span class='tooltiptext' style='width:150%;'><i>重要:</i>搜索后如有匹配内容高亮,进入下一条笔记前请清空搜索框消除高亮内容,否则高亮标记将被此条笔记保存。</span>
	</div>
</div>
<!--<button onclick='pycmd("ankinext!speedfocus#")'>下一题</button>-->

<div id='hideOp' style='display:none'>
	<hr><a>可以在下面编辑选项:</a><br>{{edit:选项}}
	<hr>
	<div><a>可以在下面编辑答案,选项从上至下从A开始编号:</a><br>{{edit:答案}}</div>
</div>
<!--供jquery快速获取DocumentFragment中的HTML的空元素-->
<div id='tmp' style='display:none'></div>

<script>
	var divTmp = $('div#tmp');
	var contenteditable = $('[contenteditable=true]')[0];
	//取得牌组名
	var deck = $(contenteditable).data('deck');
	var roman = Persistence.getItem(`${deck}.roman`);
	//根据是否使用罗马数字为选项编号的设置确定选项编号样式
	if (roman) {
		$('.options').css('listStyle', 'upper-roman');
	} else {
		$('.options').css('listStyle', 'upper-latin');
	}
	//如果是移动端,因为复习界面编辑功能无法使用,不显示格式编辑及查找内容控件
	//if (/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)) {
	if (!contenteditable) {//移动端不支持插件,contenteditable为undefined,这样判断更准确
		$('div#styleButtons').css('display', 'none');
	}
	
	//匹配除br、table、td、tr、img、div、p以外的其他HTML标签的正则表达式
	var regRetain = /<\/?(((?!img\b|table\b|tr\b|td\b|br\b|div\b|p\b)\w)+[^>]*)>/gi;
	//匹配带属性的div和p元素
	var regDivWithAttr = /<(div|p|br)\s[^>]+>(((?!<\/\1>).)*)<\/\1>/gi;

	/*以下对在复习界面中选择的文本的显示样式进行处理,并支持在必要时显示选项进行修改*/
	function onBtnsClick(ele) {
		if (ele === 'editOptions') {//切换选项答案编辑区的显示与隐藏
			const hideOps = document.getElementById('hideOp');
			const btnEle = $('#editOptions');
			if (hideOps.style.display === 'none') {
				hideOps.style.display = 'block';
				btnEle.text('隐藏选项与答案');
			} else {
				hideOps.style.display = 'none';
				btnEle.text('编辑选项与答案');
			}
			return;
		}

		const selection = document.getSelection();
		//检查选择的内容是否在可编辑字段中
		let node = $(selection.anchorNode);
		let field = node.data('field');

		/*用node[0]将jquery对象转换为DOM对象,查找field直至找到或node[0]无定义*/
		while (node[0] !== undefined && field === undefined) {
			node = node.parent();
			field = node.data('field');
		}
		if (field === undefined) {
			alert('没有选择可编辑字段内容!');
			return;//node[0]无定义还未找到field,选择区不在可编辑字段中,直接返回
		}

		//构造当前编辑字段的jquery对象
		const edited = $(`[data-field=${field}]`);
		const regNullEle = /<((?!t[rd])\w+)\b[^>]*?><\/\1>/gi;
		if (ele === 'dis_html') {
			const targEle = $('#dis_html');
			nodeText = node[0].innerText;
			if (targEle.text() === '保存HTML') {//点击了保存HTML
				//编辑区文本内容为HTML,清除没有内容的HTML元素赋值给编辑区的innerHTML并保存
				node[0].innerHTML = nodeText.replace(regNullEle, '');
				targEle.text('编辑HTML');
				targEle.toggleClass('savehtml');
				pycmd("ankisave#" + edited.data("field") + "#" + edited.data("nid") + "#" + edited.html());
			} else {
				node[0].innerText = node[0].innerHTML;
				targEle.text('保存HTML');
				targEle.toggleClass('savehtml');
			}
			node.focus();
			return;
		}

		const selectStr = selection.toString().trim();
		if (selectStr === '') {
			if (ele === 'clear_format') {
				let html = edited.html();
				//删除br、表格、图片、div、p元素以外的HTML标签
				html = html.replace(regRetain, '');
				//删除带属性的div和p元素
				html = html.replace(regDivWithAttr, '$2');
				//清除没有内容的HTML元素
				html = html.replace(regNullEle, '');
				edited.html(html);
				pycmd("ankisave#" + edited.data("field") + "#" + edited.data("nid") + "#" + html);
			} else
				alert('请先选择可编辑字段中的文本!');
			return;
		}

		const range = selection.getRangeAt(0);
		/*Anki浏览器的行为特征是选择区在#text节点中,不会包括包含#text节点的格式化标签,
		* 当用户选择格式化的文本时,由于没有包含格式化文本的标签,因此无法删除格式。这种情	
		* 况下需要将选择区扩展为格式化标签的完整节点。
		*/
		let startNode = range.startContainer;
		if (startNode.nodeType === 3)//通常选择的文字当做处于文本节点中
			startNode = startNode.parentNode;//定位到HTML节点
		let endNode = range.endContainer;
		if (endNode.nodeType === 3)
			endNode = endNode.parentNode;
		if (startNode === endNode) {//选区开始和结束位置在同一个标签之内
			//选择区包含了某个元素的全部内容,则将选择区扩展到包含了格式化标签的整个结点
			if (range.cloneContents().textContent === startNode.textContent) {
				const node = $(startNode);
				const nodeName = node.prop('nodeName').toLowerCase();
				if(node.data('field') === undefined && nodeName !== 'td'){
					range.selectNode(startNode);
					//range.setStart(startNode);
					//range.setEnd(startNode,startNode.childNodes.length);
				}
			} else if (ele === 'clear_format') {
				const selectHTML = divTmp.empty().append(range.cloneContents()).html();
				if (/<\/?\w+\b[^>]*?>/gi.test(selectHTML))
					clearFormat(range);//选区包含HTML标签,直接清除格式
				else {//选区无HTML标签,将所选择的字符串前面加上所在元素的结束标签,后面加上所在元素的开始标签
					if($(startNode).data('field') !== undefined)
						return;
					const parentNode = $(startNode).parent();
					const parentHtml = parentNode.html();//选区父元素的HTML
					const startNodeRange = document.createRange();
					startNodeRange.selectNode(startNode);
					//选区所属元素包含元素标签在内的HTML
					const origHtml = divTmp.empty().append(startNodeRange.cloneContents()).html();
					const nodeName = startNode.nodeName.toLowerCase();
					//在选区前添加startNode的结束标签,在选区后添加startNode的开始标签,使选区脱离startNode
					//同时,为了能够在清除格式后不丢失选区以供进一步调整格式,用一个自定义标签名tmpele包围文字。
					let newHtml = origHtml.replace(selectStr, `</${nodeName}><tmpele>${selectStr}</tmpele><${nodeName}>`);
					//将父元素中的整个startNode结点更新后替换掉父元素中的HTML
					newHtml = parentHtml.replace(origHtml, newHtml);
					parentNode.html(newHtml);
					const tmpele = parentNode.find('tmpele')[0];
					range.selectNode(tmpele);
				}
			}
		} else {
			const selectFrag = range.cloneContents();//选中内容的HTML片段
			//获取选中内容的完整HTML片段
			let fragHTML = divTmp.empty().append(selectFrag).html();
			if(/<su[bp][^>]*>/gi.test(fragHTML)){
				range.setEndAfter(endNode);
			}else{
				clearFormat(range);
			}
		}
		if (ele === 'sub' || ele === 'sup') {
			dealSubp(ele, range);
		} else if (ele === 'clear_format') {
			clearFormat(range);
		} else {
			insteadEle(ele, range);
		}
		//利用插件的功能保存修改。保存前先清除没有内容的HTML元素。
		const html = edited.html().replace(regNullEle, '');
		edited.html(html);
		pycmd("ankisave#" + edited.data("field") + "#" + edited.data("nid") + "#" + html);
	}

	function insteadEle(ele, range) {
		let tmpEle = document.createElement(ele);
		range.surroundContents(tmpEle);//将选中的内容用临时元素包裹起来
		range.deleteContents();//删除选中内容
		range.insertNode(tmpEle);//插入临时元素替换内容
	}

	function dealSubp(ele, range) {
		const selectFrag = range.cloneContents();//选中内容的文档片段(DocumentFragment)
		const fragHTML = divTmp.empty().append(selectFrag).html();
		//匹配上下标的正则表达式
		const regSubp = new RegExp(`<${ele}>`, 'gi');
		if (regSubp.test(fragHTML)) {
			clearFormat(range);//选择的内容中为整个上标或下标则清除格式
		} else {
			insteadEle(ele, range);
		}
	}

	function clearFormat(range) {
		const selectFrag = range.cloneContents();//选中内容的HTML片段
		//获取选中内容的完整HTML片段
		let fragHTML = divTmp.empty().append(selectFrag).html();
		fragHTML = fragHTML.replace(regRetain, '');
		fragHTML = fragHTML.replace(regDivWithAttr, '$2');
		const oFragment = range.createContextualFragment(fragHTML);
		range.deleteContents();//删除选中内容
		range.insertNode(oFragment);
		//在清除格式后仍然保持选区
		range.selectNode(oFragment);
		const selection = document.getSelection();
		selection.removeAllRanges();
		selection.addRange(range);
	}

	/*处理高亮*/
	$(function () {
		$('#search').on('input.highlight', function () {
			// 获取指定搜索的字符串
			var searchTerm = $(this).val();
			// 将解析中匹配的文本高亮
			$('.extra').unmark().mark(searchTerm,
				{
					'acrossElements': true,
					'separateWordSearch': false,
				}
			);
		}).trigger('input.highlight');
	});


	/*以下处理对做题结果的判断和输出*/
	var myinfo = Persistence.getItem(`${deck}.myinfo`);	
	//计算成绩的函数
	function calcScore() {
		myinfo.total ++;
		if(myinfo.newOrderAnswer.length > 1){
			myinfo.multi ++;
			myinfo.totalScore += 2;
		} else {
			myinfo.single ++;
			myinfo.totalScore ++;
		}
		if (myinfo.choiced.length === 0) {
			myinfo.ifright = '为什么一个都不选?'
			myinfo.score = 0;
		} else {
			myinfo.score = 1;
			for (let i = 0; i < myinfo.choiced.length; i++) {
				if (myinfo.newOrderAnswer.indexOf(myinfo.choiced.charAt(i)) < 0) {
					myinfo.score = 0;
					myinfo.ifright = '错误';
					break;
				}
			}
			if (myinfo.score != 0) {
				if (myinfo.newOrderAnswer.length === 1) {
					myinfo.singleCorrect++;
					myinfo.score = 1;
					myinfo.ifright = '完全正确';
				} else {
					if (myinfo.choiced.length === myinfo.newOrderAnswer.length) {
						myinfo.multiCorrect++;
						myinfo.multiScore += 2;
						myinfo.score = 2;
						myinfo.choiced = myinfo.newOrderAnswer;
						myinfo.ifright = '完全正确';
					} else {
						myinfo.partCorrect++;
						myinfo.score = myinfo.choiced.length * 0.5;
						myinfo.multiScore += myinfo.score;
						myinfo.ifright = '不完全正确';
					}
				}
			}
		}
		myinfo.sum += myinfo.score;
		Persistence.setItem(`${deck}.myinfo`, myinfo);
		
	}
	//显示选项
	var optionOl = document.getElementById('optionList');
	var ops = myinfo.newOrderOps;
	var optionsStyle = $('.options').css('listStyle');
	var charCode = optionsStyle.indexOf('upper-latin') < 0 ? 8544 : 65;
	for (let i = 0; i < ops.length; i++) {
		let ch = String.fromCharCode(charCode + i);
		list = document.createElement('li');
		label = document.createElement('label');
		label.innerHTML = ops[i];
		const input = document.createElement('input');
		//根据选择的答案是否有多个字符判断应选用多选框还是单选框
		input.type = myinfo.choiced.length > 1 ? 'checkbox' : 'radio';
		list.appendChild(input);
		list.appendChild(label);
		optionOl.appendChild(list);
		if (myinfo.newOrderAnswer.indexOf(ch) >= 0) {
			if (myinfo.choiced.indexOf(ch) >= 0) {
				list.className = 'green';
				input.checked = true;
			} else {
				list.className = 'blue';
			}
		} else {
			if (myinfo.choiced.indexOf(ch) >= 0) {
				list.className = 'wrong';
				input.checked = true;
			} else {
				list.className = 'unchoiced'
			}
		}
	}
	var calc = Persistence.getItem(`${deck}.calc`);
	var times = Persistence.getItem(`${deck}.times`);
	if(calc === null) 
		calc = true;	
	//仅在不是从编辑界面回到复习界面时计算成绩,如果是从编辑界面回到复习界面,重置插件中的标志值
	if(calc && times === 2)	{
		calcScore();
	}else{
		times -= 1;
		if(!times){
			times = 2;
			Persistence.setItem(`${deck}.calc`,true);
		}
		Persistence.setItem(`${deck}.times`,times);
	}
	
	//显示成绩	
	var performance = document.getElementById('performance');
	var key = document.getElementById('key');
	var total = myinfo.single + myinfo.multi;
	if (typeof (myinfo) != 'undefined') {
		let singlePer = myinfo.single === 0 ? '100.00' :
			((myinfo.singleCorrect / myinfo.single) * 100).toFixed(2);
		let multiErr = myinfo.multi - myinfo.multiCorrect - myinfo.partCorrect
		let multiPer = myinfo.multi === 0 ? '100.00' :
			((myinfo.multiScore / (myinfo.multi * 2)) * 100).toFixed(2);
		let scorePer = ((myinfo.sum / myinfo.totalScore) * 100).toFixed(2)
		performance.innerHTML =
			`本次练习<span class='red'>${total}</span>题
			---单选题<span class='red'>${myinfo.single}</span>题
			---多选题<span class='red'>${myinfo.multi}</span>题;<br>
			单选正确<span class='red'>${myinfo.singleCorrect}</span>题
			---单选正确率<span class='red'>${singlePer}%</span>;<br>
			多选正确<span class='red'>${myinfo.multiCorrect}</span>题
			---多选部分正确<span class='red'>${myinfo.partCorrect}</span>题
			---多选错误<span class='red'>${multiErr}</span>题
			---多选得分<span class='red'>${myinfo.multiScore}</span>分
			---多选得分率<span class='red'>${multiPer}%</span>;<br>
			累计得分:<span class='pink'>${myinfo.sum}</span>分
			---已做题目满分<span class='pink'>${myinfo.totalScore}</span>分
			---得分率<span class='pink'>${scorePer}%</span>。`;
		key.innerHTML =
			`${key.innerHTML}<div>正确答案:<span class='imp'>${myinfo.newOrderAnswer};</span>
			你的答案:<span class='imp'>${myinfo.choiced};</span>
			结果判定:<span class='imp'>${myinfo.ifright}</span>;
			本题得分:<span class='imp'>${myinfo.score}</span>。</div>`;
	}
</script>

样式:

.card {
	font-family: 'roman',Cambria-modify, 干就完事了简, 微软雅黑;
	font-size: 1.3em;
	text-align: left;
	color: #ddd;
	background-color: #000000;
}

table {
	border-collapse: collapse;
}

td {
	padding: 5px;
	text-align: center;
	border: 2px solid green;
	vertical-align: middle;
}

td.left {
	text-align: left;
}

td.red {
	border-right: solid thick red;
}

hr {
	border: none;
	height: 5px;
	background-color: yellow;
}

p {
	text-indent: 2em;
}

ul {
	list-style-type: none;
}

div {
	margin: 5px auto
}

.text {
	color: #ff0;
	font-weight: bold;
	font-size: 1.2em;
}

.orange,
.imp,
a,
a:visited,
a:hover,
a:link,
a:active {
	color: #f90;
	font-weight: bold;
	font-family:Cambria-modify,'Aa奶糖油画体', '微软雅黑';
}

.pink{
	color: #f9f;
	font-weight: bold;
}

u,
.red {
	color: #f00;
	font-weight: bold;
	text-decoration: none;
	font-family:Cambria-modify,'Aa虎头虎脑', '微软雅黑';
}

.unchoiced {
	color: #ccc;
}

.choiced {
	font-weight: bold;
	color: #f00;
	background-color: green;
}

.extra {
	margin-top: 15px;
	font-size: 1.2em;
	text-align: left;
	line-height: 1.5em;
	color:#aaa;
}

.green,
i {
	font-weight: bold;
	font-style: normal;
	color: #0f0;
	font-family:Cambria-modify,'Aa奇思胖丫儿', '微软雅黑';
}

.blue,
b {
	font-weight: bold;
	font-style: normal;
	color: #3cf;
	font-family:Cambria-modify,'Aa可爱の日系中文2万字', '微软雅黑';
}

.wrong {
	font-weight: bold;
	color: red;
	text-decoration: line-through;
}

.options {
	font-size: 1.2em;
}

.options * {
	cursor: pointer;
}

.options *:hover[class="options"] {
	font-weight: bold;
	color: #f90;
}

.options li {
	margin-top: 0.8em;
}

img{
	display:block;
}

/*下面两行样式定义决定是否显示选项前面的圆形或方形框,注释掉就会显示*/
.options input[type="radio"] {
	display: none;
}

.options input[type="checkbox"] {
	display: none;
}

#performance {
	text-align: left;
	font-size: 16px;
}

mark {
	/*用于高亮搜索文本*/
	background: yellow;
	color: red;
}

.tooltip {
	position: relative;
	display: inline-block;
}

.tooltip .tooltiptext {
	visibility: hidden;
	opacity: 0;
	transition: opacity 1s linear,visibility 1s;
	background-color: #909;
	text-align: left;
	width: 100%;
	text-indent: 2em;
	font-family: Cambria-modify, 'Aa虎头虎脑', '微软雅黑';
	border-radius: 6px;
	padding: 5px 3px;
	color: #ff0;
	top: 1.3em;
	position: absolute;
	z-index: 1;
}

.tooltip:hover .tooltiptext {
	visibility: visible;
	opacity: 1;
	transition: opacity 1s linear;
}


:root{
    --primary-color: royalblue;
}
.button{
    padding: 0.5em 1em;
    color: #ff6622;
    border: 1px solid #d9d9d9;
    #background-color: transparent;
    line-height: 1em;
    box-shadow: 0 2px #00000004;
    cursor: pointer;
    transform: scale(1);
    animation: jump 0s;
    transition: .3s;
		font-weight:bold;
}
.button:focus-visible{
    outline: 0;
}
.button[type="primary"]{
    border-color: transparent;
    background-color: var(--primary-color);
    color: #fff;
    text-shadow: 0 -1px 0 rgb(0 0 0 / 12%);
    box-shadow: 0 2px #0000000b;
}
.button[type="dashed"]{
    border-style: dashed;
}
.button[type="text"]{
    border-color: transparent;
    box-shadow: none;
}
.button:not([type]):hover,
.button[type="dashed"]:hover,
.button[type="dashed"]:focus{
    color: var(--primary-color);
    border-color: currentColor;
}
.button[type="primary"]:hover{
    filter: brightness(1.1);
}
.button[type="text"]:hover,
.button[type="text"]:focus{
    background: rgba(0,0,0,.018);
}
.tada{
  animation: tada 0s;
}
.rubberBand{
  animation: rubberBand 0s;
}
.jello{
  animation: jello 0s;
}
body:hover .button{
    animation-duration: 1s;
}
.button.button:active{
    filter: brightness(.9);
    animation: none;
}
@keyframes tada {
    from {
        transform: scale3d(1, 1, 1)
    }

    10%, 20% {
        transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg)
    }

    30%, 50%, 70%, 90% {
        transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)
    }

    40%, 60%, 80% {
        transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg)
    }

    to {
        transform: scale3d(1, 1, 1)
    }
}

@keyframes rubberBand {
    0% {
        -webkit-transform: scaleX(1);
        transform: scaleX(1)
    }

    30% {
        -webkit-transform: scale3d(1.25,.75,1);
        transform: scale3d(1.25,.75,1)
    }

    40% {
        -webkit-transform: scale3d(.75,1.25,1);
        transform: scale3d(.75,1.25,1)
    }

    50% {
        -webkit-transform: scale3d(1.15,.85,1);
        transform: scale3d(1.15,.85,1)
    }

    65% {
        -webkit-transform: scale3d(.95,1.05,1);
        transform: scale3d(.95,1.05,1)
    }

    75% {
        -webkit-transform: scale3d(1.05,.95,1);
        transform: scale3d(1.05,.95,1)
    }

    to {
        -webkit-transform: scaleX(1);
        transform: scaleX(1)
    }
}
@keyframes jello {
    0%,11.1%,to {
        -webkit-transform: translateZ(0);
        transform: translateZ(0)
    }

    22.2% {
        -webkit-transform: skewX(-12.5deg) skewY(-12.5deg);
        transform: skewX(-12.5deg) skewY(-12.5deg)
    }

    33.3% {
        -webkit-transform: skewX(6.25deg) skewY(6.25deg);
        transform: skewX(6.25deg) skewY(6.25deg)
    }

    44.4% {
        -webkit-transform: skewX(-3.125deg) skewY(-3.125deg);
        transform: skewX(-3.125deg) skewY(-3.125deg)
    }

    55.5% {
        -webkit-transform: skewX(1.5625deg) skewY(1.5625deg);
        transform: skewX(1.5625deg) skewY(1.5625deg)
    }

    66.6% {
        -webkit-transform: skewX(-.78125deg) skewY(-.78125deg);
        transform: skewX(-.78125deg) skewY(-.78125deg)
    }

    77.7% {
        -webkit-transform: skewX(.390625deg) skewY(.390625deg);
        transform: skewX(.390625deg) skewY(.390625deg)
    }

    88.8% {
        -webkit-transform: skewX(-.1953125deg) skewY(-.1953125deg);
        transform: skewX(-.1953125deg) skewY(-.1953125deg)
    }
}

十分神奇的是,背面内容模板中下面两行代码:

//匹配除br、table、td、tr、img、div、p以外的其他HTML标签的正则表达式
const regRetain = /<\/?(((?!img\b|table\b|tr\b|td\b|br\b|div\b|p\b)\w)+[^>]*)>/gi;
//匹配带属性的div和p元素
const regDivWithAttr = /<(div|p)\s[^>]+>(((?!<\/\1>).)*)<\/\1>/gi;

我如果把它定义在<script>标签下供clearFormat函数和onBtnsClick函数共用而不是在两个函数中重复定义,会导致背面内容模板无法成功从持久化系统中取得myinfo。这个莫名其妙的问题花了我好几个小时,实在是想不到这样做会出错。ps:现在搞清楚了,在script标签下定义供clearFormat函数和onBtnsClick函数共用,不能用const关键字,要用var关键字。不知道为什么。

最后说明:本文模板在windows11及Anki⁨24.06.3上测试可正常使用。

不相关的附记:搜狗拼音输入法按住shift+u键会进入u模式,这时候用hzpdn可以通过笔画输入文字,输入汉字偏旁时比较方便。

评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yivifu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值